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:
OpenSquared
2026-06-20 11:00:31 +02:00
parent 39da3b8945
commit d4a016a535
5 changed files with 444 additions and 144 deletions

View File

@@ -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."""

View File

@@ -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(

View File

@@ -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({

View File

@@ -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()

View File

@@ -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>
)