feat: P&L côte à côte (Ouvert|Réalisé) + filtres journal + suppression trades fermés
- Dashboard: P&L card séparé en deux colonnes (Ouvertes/Réalisées) pour Simulé et Portfolio
- Dashboard: closed trades P&L locked from pnl_realized, ne fluctue plus après fermeture
- Journal Ouvert: filtres ticker/stratégie + classe d'actif + direction (haussier/baissier)
- Journal Fermés: mêmes filtres + filtre P&L (gagnants/perdants) + bouton supprimer par ligne
- Journal Non loggés: filtres ticker + classe d'actif + raison de skip
- Backend: DELETE /api/journal/trades/{id} + delete_trade() dans database.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ from services.database import (
|
||||
get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices,
|
||||
get_closed_trades, close_trade, update_trade_exit_params,
|
||||
get_trade_entry_by_id, get_config, set_config, reset_journal_history,
|
||||
_fetch_live_prices, _trade_maturity, get_skipped_trades,
|
||||
_fetch_live_prices, _trade_maturity, get_skipped_trades, delete_trade,
|
||||
)
|
||||
import json
|
||||
|
||||
@@ -61,11 +61,35 @@ def trade_mtm(days: int = 30):
|
||||
for e in entries:
|
||||
ticker = (e.get("underlying") or "").upper()
|
||||
entry_price = e.get("entry_price")
|
||||
current_price = current_prices.get(ticker)
|
||||
pnl_pct = None
|
||||
if entry_price and current_price and entry_price > 0:
|
||||
raw_pnl = (current_price - entry_price) / entry_price * 100
|
||||
pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2)
|
||||
is_closed = e.get("status") == "closed"
|
||||
|
||||
# For closed trades: pnl_pct is fixed from pnl_realized (locked in).
|
||||
# For open trades: compute live from current market price.
|
||||
if is_closed:
|
||||
pnl_realized = e.get("pnl_realized")
|
||||
capital = e.get("capital_invested") or entry_price or 0
|
||||
if pnl_realized is not None and capital and capital > 0:
|
||||
pnl_pct = round(pnl_realized / capital * 100, 2)
|
||||
elif e.get("close_price") and entry_price and entry_price > 0:
|
||||
raw = (e["close_price"] - entry_price) / entry_price * 100
|
||||
pnl_pct = round(-raw if _is_bearish(e.get("strategy", "")) else raw, 2)
|
||||
else:
|
||||
pnl_pct = None
|
||||
current_price = e.get("close_price")
|
||||
price_warning = None if pnl_pct is not None else "no_close_price"
|
||||
else:
|
||||
current_price = current_prices.get(ticker)
|
||||
pnl_pct = None
|
||||
if entry_price and current_price and entry_price > 0:
|
||||
raw_pnl = (current_price - entry_price) / entry_price * 100
|
||||
pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2)
|
||||
price_warning = None
|
||||
if entry_price is None and current_price is None:
|
||||
price_warning = "no_price_data"
|
||||
elif entry_price is None:
|
||||
price_warning = "no_entry_price"
|
||||
elif current_price is None:
|
||||
price_warning = "no_live_price"
|
||||
|
||||
days_held = None
|
||||
try:
|
||||
@@ -74,27 +98,19 @@ def trade_mtm(days: int = 30):
|
||||
pass
|
||||
|
||||
horizon = e.get("horizon_days") or 90
|
||||
maturity = _trade_maturity(days_held or 0, horizon)
|
||||
maturity = "closed" if is_closed else _trade_maturity(days_held or 0, horizon)
|
||||
|
||||
defaults = _get_exit_defaults()
|
||||
target = e.get("target_pct") if e.get("target_pct") is not None else defaults.get("target_pct", 30.0)
|
||||
stop = e.get("stop_loss_pct") if e.get("stop_loss_pct") is not None else defaults.get("stop_loss_pct", -50.0)
|
||||
|
||||
alert_type = None
|
||||
if pnl_pct is not None:
|
||||
if not is_closed and pnl_pct is not None:
|
||||
if pnl_pct >= target:
|
||||
alert_type = "target_reached"
|
||||
elif pnl_pct <= stop:
|
||||
alert_type = "stop_loss"
|
||||
|
||||
price_warning = None
|
||||
if entry_price is None and current_price is None:
|
||||
price_warning = "no_price_data"
|
||||
elif entry_price is None:
|
||||
price_warning = "no_entry_price"
|
||||
elif current_price is None:
|
||||
price_warning = "no_live_price"
|
||||
|
||||
result.append({
|
||||
**e,
|
||||
"current_price": current_price,
|
||||
@@ -260,6 +276,14 @@ def skipped_trades_endpoint(days: int = 30):
|
||||
return _sanitize({"trades": trades, "days": days, "count": len(trades)})
|
||||
|
||||
|
||||
@router.delete("/trades/{trade_id}")
|
||||
def delete_trade_endpoint(trade_id: int):
|
||||
ok = delete_trade(trade_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Trade non trouvé")
|
||||
return {"deleted": True, "trade_id": trade_id}
|
||||
|
||||
|
||||
@router.delete("/reset")
|
||||
def reset_journal():
|
||||
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""
|
||||
|
||||
@@ -1327,6 +1327,14 @@ def get_closed_trades(days: int = 180) -> List[Dict[str, Any]]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_trade(trade_id: int) -> bool:
|
||||
conn = get_conn()
|
||||
cur = conn.execute("DELETE FROM trade_entry_prices WHERE id = ?", (trade_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_skipped_trades(days: int = 30) -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -438,6 +438,18 @@ export const useCloseTrade = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteTrade = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/journal/trades/${id}`).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['journal-mtm'] })
|
||||
qc.invalidateQueries({ queryKey: ['journal-closed'] })
|
||||
qc.invalidateQueries({ queryKey: ['journal-summary'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateExitParams = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -347,97 +347,148 @@ export default function Dashboard() {
|
||||
{/* PnL */}
|
||||
{(() => {
|
||||
const trades: any[] = mtmTrades
|
||||
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
|
||||
const avgPnl = withPnl.length
|
||||
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
|
||||
: null
|
||||
const winners = withPnl.filter((t: any) => t.pnl_pct > 0).length
|
||||
const losers = withPnl.filter((t: any) => t.pnl_pct < 0).length
|
||||
const closedTrades = trades.filter((t: any) => t.status === 'closed')
|
||||
const openTrades = trades.filter((t: any) => t.status !== 'closed')
|
||||
// Use capital_invested if set, otherwise entry_price as proxy cost
|
||||
const totalCapital = trades.reduce((s: number, t: any) =>
|
||||
s + (t.capital_invested ?? t.entry_price ?? 0), 0)
|
||||
const totalProfit = withPnl.reduce((s: number, t: any) =>
|
||||
s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0)
|
||||
const closedTrades = trades.filter((t: any) => t.status === 'closed')
|
||||
|
||||
// Unrealized — open positions
|
||||
const openWithPnl = openTrades.filter((t: any) => t.pnl_pct != null)
|
||||
const openCapital = openTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
|
||||
const openProfit = openWithPnl.reduce((s: number, t: any) => s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0)
|
||||
const openPnlPct = openCapital > 0 ? openProfit / openCapital * 100 : null
|
||||
const openWinners = openWithPnl.filter((t: any) => t.pnl_pct > 0).length
|
||||
const openLosers = openWithPnl.filter((t: any) => t.pnl_pct < 0).length
|
||||
const targetHit = openTrades.filter((t: any) => t.alert_type === 'target_reached').length
|
||||
const stopHit = openTrades.filter((t: any) => t.alert_type === 'stop_loss').length
|
||||
|
||||
// Realized — closed positions (pnl_pct locked from close_price/pnl_realized by backend)
|
||||
const closedCapital = closedTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
|
||||
const closedProfit = closedTrades.reduce((s: number, t: any) => {
|
||||
const cap = t.capital_invested ?? t.entry_price ?? 0
|
||||
return s + (t.pnl_pct != null ? cap * t.pnl_pct / 100 : 0)
|
||||
}, 0)
|
||||
const closedWinners = closedTrades.filter((t: any) => (t.pnl_pct ?? 0) > 0).length
|
||||
const closedLosers = closedTrades.filter((t: any) => (t.pnl_pct ?? 0) < 0).length
|
||||
|
||||
const isEstimated = trades.some((t: any) => t.capital_invested == null && t.entry_price != null)
|
||||
const targetHit = trades.filter((t: any) => t.alert_type === 'target_reached').length
|
||||
const stopHit = trades.filter((t: any) => t.alert_type === 'stop_loss').length
|
||||
const totalCapital = openCapital + closedCapital
|
||||
const totalProfit = openProfit + closedProfit
|
||||
|
||||
const pf = portfolio as any
|
||||
const pfPnlPct = pf?.unrealized_pnl_pct ?? null
|
||||
const pfPnl = pf?.unrealized_pnl ?? null
|
||||
const pfInvest = pf?.total_invested ?? null
|
||||
const pfReal = pf?.realized_pnl ?? null
|
||||
const pfNet = pf?.net_pnl ?? null
|
||||
|
||||
return (
|
||||
<Link to="/journal" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="section-title mb-0 text-[10px]">📊 P&L</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ViewToggle value={pnlView} onChange={setPnlViewPersist} />
|
||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
{pnlView === 'simulated' ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between mt-1">
|
||||
<div>
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
|
||||
|
||||
{/* ── Side-by-side: Unrealized | Realized ── */}
|
||||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||
{/* Left: Unrealized */}
|
||||
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
|
||||
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Ouvertes</div>
|
||||
{pnlView === 'simulated' ? (
|
||||
<>
|
||||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||||
openPnlPct === null ? 'text-slate-600' : openPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{openPnlPct !== null ? `${openPnlPct >= 0 ? '+' : ''}${openPnlPct.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className={clsx('text-xs font-mono mt-0.5',
|
||||
openProfit >= 0 ? 'text-emerald-500' : 'text-red-400')}>
|
||||
{openCapital > 0 ? `${openProfit >= 0 ? '+' : ''}${openProfit.toFixed(0)}€` : '—'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">
|
||||
{trades.length} trades · <span className="text-emerald-500">{winners}✓</span>{' '}
|
||||
<span className="text-red-400">{losers}✗</span>
|
||||
{openTrades.length} trades · <span className="text-emerald-500">{openWinners}✓</span>{' '}
|
||||
<span className="text-red-400">{openLosers}✗</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-0.5">
|
||||
{targetHit > 0 && <div className="text-[10px] text-emerald-400">🎯 {targetHit} cible{targetHit > 1 ? 's' : ''}</div>}
|
||||
{stopHit > 0 && <div className="text-[10px] text-red-400">⛔ {stopHit} stop</div>}
|
||||
<div className="text-[10px] text-slate-600">{withPnl.length} pricés</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-slate-700/30 flex justify-between items-end">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">
|
||||
Investi{isEstimated ? ' ~' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||||
pfPnlPct === null ? 'text-slate-600' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono text-slate-300">
|
||||
{totalCapital > 0 ? `${totalCapital.toFixed(0)}€` : '—'}
|
||||
<div className={clsx('text-xs font-mono mt-0.5', (pfPnl ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-400')}>
|
||||
{pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}€` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">P&L €</div>
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
totalCapital === 0 ? 'text-slate-600' : totalProfit >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}€` : '—'}
|
||||
<div className="text-[10px] text-slate-500 mt-1">
|
||||
{pf?.open_positions ?? 0} positions
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(targetHit > 0 || stopHit > 0) && (
|
||||
<div className="mt-1 text-[9px] space-x-1">
|
||||
{targetHit > 0 && <span className="text-emerald-400">🎯{targetHit}</span>}
|
||||
{stopHit > 0 && <span className="text-red-400">⛔{stopHit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-end justify-between mt-1">
|
||||
<div>
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
pfPnlPct === null ? 'text-slate-500' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">
|
||||
{pf?.open_positions ?? 0} positions ouvertes
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-0.5">
|
||||
{pfInvest != null && <div className="text-[10px] text-slate-400 font-mono">{pfInvest.toFixed(0)}€ investi</div>}
|
||||
{pfPnl != null && <div className={clsx('text-[10px] font-mono font-bold', pfPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pfPnl >= 0 ? '+' : ''}{pfPnl.toFixed(0)}€
|
||||
</div>}
|
||||
{pfReal != null && pfReal !== 0 && <div className={clsx('text-[10px] font-mono', pfReal >= 0 ? 'text-emerald-600' : 'text-red-600')}>
|
||||
réal. {pfReal >= 0 ? '+' : ''}{pfReal.toFixed(0)}€
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Realized */}
|
||||
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
|
||||
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Réalisé</div>
|
||||
{pnlView === 'simulated' ? (
|
||||
<>
|
||||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||||
closedTrades.length === 0 ? 'text-slate-600'
|
||||
: closedProfit >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{closedTrades.length === 0 ? '—'
|
||||
: `${closedProfit >= 0 ? '+' : ''}${closedProfit.toFixed(0)}€`}
|
||||
</div>
|
||||
<div className="text-xs font-mono mt-0.5 text-slate-500">
|
||||
{closedCapital > 0
|
||||
? `${(closedProfit / closedCapital * 100).toFixed(1)}%`
|
||||
: closedTrades.length > 0 ? 'sans capital' : ''}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">
|
||||
{closedTrades.length} fermés · <span className="text-emerald-500">{closedWinners}✓</span>{' '}
|
||||
<span className="text-red-400">{closedLosers}✗</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||||
pfReal == null || pfReal === 0 ? 'text-slate-600'
|
||||
: pfReal > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pfReal != null && pfReal !== 0
|
||||
? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}€`
|
||||
: '—'}
|
||||
</div>
|
||||
{pfNet != null && (
|
||||
<div className={clsx('text-xs font-mono mt-0.5', pfNet >= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}>
|
||||
net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}€
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-500 mt-1">
|
||||
{pf?.closed_positions ?? 0} fermées
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total line */}
|
||||
<div className="mt-1.5 flex items-center justify-between text-[10px] flex-shrink-0">
|
||||
<span className="text-slate-600">
|
||||
Investi{isEstimated ? ' ~' : ''} {pnlView === 'simulated'
|
||||
? (totalCapital > 0 ? `${totalCapital.toFixed(0)}€` : '—')
|
||||
: (pfInvest != null ? `${pfInvest.toFixed(0)}€` : '—')}
|
||||
</span>
|
||||
<span className={clsx('font-mono font-bold',
|
||||
(pnlView === 'simulated' ? totalProfit : (pfNet ?? 0)) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
Total {pnlView === 'simulated'
|
||||
? (totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}€` : '—')
|
||||
: (pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}€` : '—')}
|
||||
</span>
|
||||
</div>
|
||||
{/* PnL historical sparkline */}
|
||||
{(() => {
|
||||
const snaps: any[] = [...(pnlHistoryData?.snapshots ?? [])].reverse()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef, Fragment } from 'react'
|
||||
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock, ShieldAlert, PieChart, Target } from 'lucide-react'
|
||||
import { TradeIdeasTab } from '../components/TradeIdeas'
|
||||
import { TradeIdeasTab, CATEGORIES } from '../components/TradeIdeas'
|
||||
import clsx from 'clsx'
|
||||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, useSkippedTrades, api } from '../hooks/useApi'
|
||||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, useSkippedTrades, useDeleteTrade, api } from '../hooks/useApi'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
@@ -227,13 +227,118 @@ function CloseTradeModal({ trade, onClose }: { trade: any; onClose: () => void }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared filter bar ────────────────────────────────────────────────────────
|
||||
|
||||
const DIRECTIONS = [
|
||||
{ key: 'all', label: 'Tous' },
|
||||
{ key: 'bullish', label: '🐂 Haussier' },
|
||||
{ key: 'bearish', label: '🐻 Baissier' },
|
||||
]
|
||||
|
||||
function _isBearishStr(strategy: string) {
|
||||
return /put|bear|short|sell|vente|baissier/i.test(strategy ?? '')
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
search: string; onSearch: (v: string) => void
|
||||
assetClass: string; onAssetClass: (v: string) => void
|
||||
direction?: string; onDirection?: (v: string) => void
|
||||
pnlFilter?: string; onPnlFilter?: (v: string) => void
|
||||
}
|
||||
|
||||
function FilterBar({ search, onSearch, assetClass, onAssetClass, direction, onDirection, pnlFilter, onPnlFilter }: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Text search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-600 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => onSearch(e.target.value)}
|
||||
placeholder="Ticker, stratégie…"
|
||||
className="pl-6 pr-2 py-1 bg-dark-700 border border-slate-700/50 rounded text-xs text-slate-300 placeholder-slate-600 w-36 focus:outline-none focus:border-slate-500"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => onSearch('')} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Asset class */}
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{CATEGORIES.map(c => (
|
||||
<button key={c.key} onClick={() => onAssetClass(c.key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-blue-600 text-white': assetClass === c.key,
|
||||
'text-slate-400 hover:text-slate-200': assetClass !== c.key,
|
||||
})}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Direction */}
|
||||
{onDirection && (
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{DIRECTIONS.map(d => (
|
||||
<button key={d.key} onClick={() => onDirection(d.key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': direction === d.key,
|
||||
'text-slate-400 hover:text-slate-200': direction !== d.key,
|
||||
})}>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* P&L filter (Fermés only) */}
|
||||
{onPnlFilter && (
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{[
|
||||
{ key: 'all', label: 'Tous' },
|
||||
{ key: 'winners', label: '✓ Gagnants' },
|
||||
{ key: 'losers', label: '✗ Perdants' },
|
||||
].map(p => (
|
||||
<button key={p.key} onClick={() => onPnlFilter(p.key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': pnlFilter === p.key,
|
||||
'text-slate-400 hover:text-slate-200': pnlFilter !== p.key,
|
||||
})}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Closed Trades Section ─────────────────────────────────────────────────────
|
||||
|
||||
function ClosedTradesSection({ days }: { days: number }) {
|
||||
const { data, isLoading, refetch, isFetching } = useClosedTrades(days)
|
||||
const trades: any[] = (data as any)?.trades ?? []
|
||||
const { mutate: deleteTrade, isPending: deleting } = useDeleteTrade()
|
||||
const allTrades: any[] = (data as any)?.trades ?? []
|
||||
const stats: any = (data as any)?.stats ?? {}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [assetClass, setAssetClass] = useState('all')
|
||||
const [direction, setDirection] = useState('all')
|
||||
const [pnlFilter, setPnlFilter] = useState('all')
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null)
|
||||
|
||||
const trades = allTrades.filter((t: any) => {
|
||||
const q = search.toLowerCase()
|
||||
if (q && !`${t.underlying} ${t.strategy ?? ''} ${t.pattern_name ?? ''}`.toLowerCase().includes(q)) return false
|
||||
if (assetClass !== 'all' && t.asset_class !== assetClass) return false
|
||||
const bearish = _isBearishStr(t.strategy ?? '')
|
||||
if (direction === 'bullish' && bearish) return false
|
||||
if (direction === 'bearish' && !bearish) return false
|
||||
if (pnlFilter === 'winners' && (t.pnl_realized ?? 0) < 0) return false
|
||||
if (pnlFilter === 'losers' && (t.pnl_realized ?? 0) >= 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const REASON_META: Record<string, { label: string; color: string }> = {
|
||||
target: { label: '🎯 Objectif', color: 'text-emerald-400' },
|
||||
stop_loss: { label: '🛑 Stop-loss', color: 'text-red-400' },
|
||||
@@ -244,7 +349,7 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats banner */}
|
||||
{trades.length > 0 && (
|
||||
{allTrades.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Trades fermés', value: stats.total ?? 0, sub: '', color: 'text-white' },
|
||||
@@ -261,9 +366,9 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
{trades.length} trades clôturés · {days} derniers jours
|
||||
{trades.length}/{allTrades.length} trades clôturés · {days} derniers jours
|
||||
</div>
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||||
@@ -271,13 +376,24 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search} onSearch={setSearch}
|
||||
assetClass={assetClass} onAssetClass={setAssetClass}
|
||||
direction={direction} onDirection={setDirection}
|
||||
pnlFilter={pnlFilter} onPnlFilter={setPnlFilter}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card h-32 animate-pulse bg-dark-700" />
|
||||
) : trades.length === 0 ? (
|
||||
) : allTrades.length === 0 ? (
|
||||
<div className="card text-center py-12 text-slate-600 text-sm">
|
||||
<Lock className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||||
Aucun trade clôturé dans les {days} derniers jours
|
||||
</div>
|
||||
) : trades.length === 0 ? (
|
||||
<div className="card text-center py-8 text-slate-600 text-sm">
|
||||
Aucun résultat pour ces filtres
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||||
<table className="w-full text-xs">
|
||||
@@ -294,6 +410,7 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
<th className="text-right px-3 py-2 font-medium">P&L réalisé</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Motif</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Note</th>
|
||||
<th className="px-2 py-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
@@ -302,13 +419,13 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
const daysHeld = t.entry_date && t.closed_at
|
||||
? Math.round((new Date(t.closed_at).getTime() - new Date(t.entry_date).getTime()) / 86400000)
|
||||
: null
|
||||
const isConfirming = confirmDeleteId === t.id
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-3 py-2 text-slate-300 max-w-[100px] truncate">{t.pattern_name || t.pattern_id}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx('badge text-[10px]',
|
||||
t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
|
||||
? 'badge-red' : 'badge-green')}>
|
||||
_isBearishStr(t.strategy ?? '') ? 'badge-red' : 'badge-green')}>
|
||||
{t.strategy || '—'}
|
||||
</span>
|
||||
</td>
|
||||
@@ -340,6 +457,30 @@ function ClosedTradesSection({ days }: { days: number }) {
|
||||
<td className="px-3 py-2 text-slate-600 text-[10px] max-w-[120px] truncate" title={t.close_note}>
|
||||
{t.close_note || '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { deleteTrade(t.id, { onSuccess: () => setConfirmDeleteId(null) }) }}
|
||||
disabled={deleting}
|
||||
className="text-[10px] font-semibold text-red-400 hover:text-red-300 bg-red-900/30 border border-red-700/40 rounded px-1.5 py-0.5 disabled:opacity-40"
|
||||
>
|
||||
{deleting ? '…' : 'Supprimer'}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDeleteId(null)} className="text-slate-600 hover:text-slate-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(t.id)}
|
||||
className="p-1 rounded text-slate-700 hover:text-red-400 hover:bg-red-900/20 transition-colors"
|
||||
title="Supprimer ce trade"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@@ -750,8 +891,20 @@ function TradeMtmSection({ days }: { days: number }) {
|
||||
const [minScoreFilter, setMinScoreFilter] = useState(0)
|
||||
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null)
|
||||
const [closingTrade, setClosingTrade] = useState<any | null>(null)
|
||||
const allTrades: any[] = (data as any)?.trades ?? []
|
||||
const trades = allTrades.filter((t: any) => (t.latest_score ?? t.score_at_entry ?? 0) >= minScoreFilter)
|
||||
const [search, setSearch] = useState('')
|
||||
const [assetClass, setAssetClass] = useState('all')
|
||||
const [direction, setDirection] = useState('all')
|
||||
|
||||
const allTrades: any[] = ((data as any)?.trades ?? []).filter((t: any) => t.status !== 'closed')
|
||||
const trades = allTrades.filter((t: any) => {
|
||||
if ((t.latest_score ?? t.score_at_entry ?? 0) < minScoreFilter) return false
|
||||
const q = search.toLowerCase()
|
||||
if (q && !`${t.underlying} ${t.strategy ?? ''} ${t.pattern_name ?? ''}`.toLowerCase().includes(q)) return false
|
||||
if (assetClass !== 'all' && t.asset_class !== assetClass) return false
|
||||
if (direction === 'bullish' && t.direction !== 'bullish') return false
|
||||
if (direction === 'bearish' && t.direction !== 'bearish') return false
|
||||
return true
|
||||
})
|
||||
|
||||
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
|
||||
const avgPnl = withPnl.length
|
||||
@@ -760,7 +913,7 @@ function TradeMtmSection({ days }: { days: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
{trades.length}/{allTrades.length} trades · {withPnl.length} pricés
|
||||
{avgPnl != null && (
|
||||
@@ -787,6 +940,12 @@ function TradeMtmSection({ days }: { days: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search} onSearch={setSearch}
|
||||
assetClass={assetClass} onAssetClass={setAssetClass}
|
||||
direction={direction} onDirection={setDirection}
|
||||
/>
|
||||
|
||||
{closingTrade && (
|
||||
<CloseTradeModal trade={closingTrade} onClose={() => setClosingTrade(null)} />
|
||||
)}
|
||||
@@ -797,8 +956,8 @@ function TradeMtmSection({ days }: { days: number }) {
|
||||
<div className="card text-center py-10 text-slate-600 text-sm">
|
||||
<TrendingUp className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||||
{allTrades.length === 0
|
||||
? 'Aucun trade logué — scorer des patterns pour commencer le suivi'
|
||||
: `Aucun trade avec score ≥ ${minScoreFilter}`}
|
||||
? 'Aucun trade ouvert — scorer des patterns pour commencer le suivi'
|
||||
: 'Aucun résultat pour ces filtres'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||||
@@ -1451,9 +1610,13 @@ const TABS = [
|
||||
|
||||
function SkippedTradesSection({ days }: { days: number }) {
|
||||
const { data, isLoading } = useSkippedTrades(days)
|
||||
const trades: any[] = (data as any)?.trades ?? []
|
||||
const allTrades: any[] = (data as any)?.trades ?? []
|
||||
|
||||
const ASSET_CLASS_COLORS: Record<string, string> = {
|
||||
const [search, setSearch] = useState('')
|
||||
const [assetClass, setAssetClass] = useState('all')
|
||||
const [reasonFilter, setReasonFilter] = useState('all')
|
||||
|
||||
const ASSET_CLASS_BADGE: Record<string, string> = {
|
||||
energy: 'bg-orange-900/30 text-orange-300 border-orange-700/30',
|
||||
metals: 'bg-yellow-900/30 text-yellow-300 border-yellow-700/30',
|
||||
agriculture: 'bg-green-900/30 text-green-300 border-green-700/30',
|
||||
@@ -1463,9 +1626,24 @@ function SkippedTradesSection({ days }: { days: number }) {
|
||||
equities: 'bg-cyan-900/30 text-cyan-300 border-cyan-700/30',
|
||||
}
|
||||
|
||||
const allReasons = Array.from(new Set(allTrades.map((t: any) => t.skip_reason).filter(Boolean)))
|
||||
|
||||
const trades = allTrades.filter((t: any) => {
|
||||
const q = search.toLowerCase()
|
||||
if (q && !`${t.underlying} ${t.strategy ?? ''} ${t.pattern_name ?? ''}`.toLowerCase().includes(q)) return false
|
||||
if (assetClass !== 'all' && t.asset_class !== assetClass) return false
|
||||
if (reasonFilter !== 'all' && t.skip_reason !== reasonFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const byReason = allTrades.reduce((acc: Record<string, number>, t: any) => {
|
||||
acc[t.skip_reason] = (acc[t.skip_reason] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (isLoading) return <div className="card animate-pulse h-24" />
|
||||
|
||||
if (trades.length === 0) {
|
||||
if (allTrades.length === 0) {
|
||||
return (
|
||||
<div className="card text-center py-8 text-slate-500 text-sm">
|
||||
<XCircle className="w-6 h-6 mx-auto mb-2 text-slate-600" />
|
||||
@@ -1474,21 +1652,18 @@ function SkippedTradesSection({ days }: { days: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
const byReason = trades.reduce((acc: Record<string, number>, t: any) => {
|
||||
acc[t.skip_reason] = (acc[t.skip_reason] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card">
|
||||
<div className="section-title flex items-center gap-1 mb-3">
|
||||
<XCircle className="w-3 h-3 text-amber-400" />
|
||||
Trades suggérés non loggés — {trades.length} sur {days}j
|
||||
Trades suggérés non loggés — {trades.length}/{allTrades.length} sur {days}j
|
||||
<span className="ml-2 text-slate-600 font-normal text-xs">
|
||||
(score ou gain insuffisant pour tout profil actif)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reason summary chips */}
|
||||
<div className="flex gap-3 mb-4 flex-wrap">
|
||||
{Object.entries(byReason).map(([reason, count]) => (
|
||||
<div key={reason} className="badge badge-yellow text-[10px]">
|
||||
@@ -1496,47 +1671,77 @@ function SkippedTradesSection({ days }: { days: number }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700/60 text-slate-600 text-[10px] uppercase tracking-wide">
|
||||
<th className="pl-3 py-2 text-left">Pattern</th>
|
||||
<th className="px-2 py-2 text-left">Ticker</th>
|
||||
<th className="px-2 py-2 text-left">Stratégie</th>
|
||||
<th className="px-2 py-2 text-right">Score</th>
|
||||
<th className="px-2 py-2 text-right">Gain attendu</th>
|
||||
<th className="px-2 py-2 text-left">Classe</th>
|
||||
<th className="pr-3 py-2 text-left">Raison du skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/40">
|
||||
{trades.map((t: any) => (
|
||||
<tr key={t.id} className="hover:bg-dark-700/30">
|
||||
<td className="pl-3 py-2 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
|
||||
<td className="px-2 py-2 font-mono text-slate-400">{t.underlying}</td>
|
||||
<td className="px-2 py-2 text-slate-400">{t.strategy || '—'}</td>
|
||||
<td className={clsx('px-2 py-2 text-right font-bold font-mono',
|
||||
t.score >= 50 ? 'text-emerald-400' : t.score >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
|
||||
{t.score ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-slate-400">
|
||||
{t.expected_move_pct != null ? `${t.expected_move_pct.toFixed(0)}%` : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{t.asset_class ? (
|
||||
<span className={clsx('badge text-[10px] border', ASSET_CLASS_COLORS[t.asset_class] ?? 'badge-blue')}>
|
||||
{t.asset_class}
|
||||
</span>
|
||||
) : <span className="text-slate-600">—</span>}
|
||||
</td>
|
||||
<td className="pr-3 py-2 text-slate-600 max-w-[200px] truncate" title={t.skip_detail}>
|
||||
{t.skip_detail || t.skip_reason}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||
<FilterBar
|
||||
search={search} onSearch={setSearch}
|
||||
assetClass={assetClass} onAssetClass={setAssetClass}
|
||||
/>
|
||||
{/* Reason filter */}
|
||||
{allReasons.length > 1 && (
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
<button onClick={() => setReasonFilter('all')}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors',
|
||||
reasonFilter === 'all' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||
Tous
|
||||
</button>
|
||||
{allReasons.map(r => (
|
||||
<button key={r} onClick={() => setReasonFilter(r)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors',
|
||||
reasonFilter === r ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trades.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-600 text-sm">Aucun résultat pour ces filtres</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700/60 text-slate-600 text-[10px] uppercase tracking-wide">
|
||||
<th className="pl-3 py-2 text-left">Pattern</th>
|
||||
<th className="px-2 py-2 text-left">Ticker</th>
|
||||
<th className="px-2 py-2 text-left">Stratégie</th>
|
||||
<th className="px-2 py-2 text-right">Score</th>
|
||||
<th className="px-2 py-2 text-right">Gain attendu</th>
|
||||
<th className="px-2 py-2 text-left">Classe</th>
|
||||
<th className="pr-3 py-2 text-left">Raison du skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/40">
|
||||
{trades.map((t: any) => (
|
||||
<tr key={t.id} className="hover:bg-dark-700/30">
|
||||
<td className="pl-3 py-2 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
|
||||
<td className="px-2 py-2 font-mono text-slate-400">{t.underlying}</td>
|
||||
<td className="px-2 py-2 text-slate-400">{t.strategy || '—'}</td>
|
||||
<td className={clsx('px-2 py-2 text-right font-bold font-mono',
|
||||
t.score >= 50 ? 'text-emerald-400' : t.score >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
|
||||
{t.score ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-slate-400">
|
||||
{t.expected_move_pct != null ? `${t.expected_move_pct.toFixed(0)}%` : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{t.asset_class ? (
|
||||
<span className={clsx('badge text-[10px] border', ASSET_CLASS_BADGE[t.asset_class] ?? 'badge-blue')}>
|
||||
{t.asset_class}
|
||||
</span>
|
||||
) : <span className="text-slate-600">—</span>}
|
||||
</td>
|
||||
<td className="pr-3 py-2 text-slate-600 max-w-[200px] truncate" title={t.skip_detail}>
|
||||
{t.skip_detail || t.skip_reason}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user