feat: cockpit command center + skipped trades journal

Dashboard: insert 2 rows of 4 mini-cards between top row and trade ideas
- Row 1: PnL Simulé, Risque Simulé, Dernier Cycle, Régime Macro
- Row 2: Super Contexte, Signaux Géo, Meilleur Pattern, Patterns Actifs
- All cards link to underlying pages via react-router Link

Journal: add 'Non loggés' tab exposing trades suggested by cycle
but skipped because no risk profile was matched
- New skipped_trades table (auto-created on backend restart)
- log_trade_entries() persists each skip with score/gain/asset_class
- GET /api/journal/skipped-trades + useSkippedTrades hook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 19:01:58 +02:00
parent 8f15dff721
commit 08651551db
5 changed files with 383 additions and 4 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,
_fetch_live_prices, _trade_maturity, get_skipped_trades,
)
import json
@@ -253,6 +253,13 @@ def trade_check(body: TradeCheckRequest):
return check_new_trade(body.underlying, body.strategy, body.asset_class)
@router.get("/skipped-trades")
def skipped_trades_endpoint(days: int = 30):
"""Trades suggested by cycle that didn't pass any risk profile threshold."""
trades = get_skipped_trades(days)
return _sanitize({"trades": trades, "days": days, "count": len(trades)})
@router.delete("/reset")
def reset_journal():
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""

View File

@@ -379,6 +379,25 @@ def init_db():
details TEXT
)""")
c.execute("""CREATE TABLE IF NOT EXISTS skipped_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT,
pattern_id TEXT,
pattern_name TEXT,
underlying TEXT,
strategy TEXT,
score INTEGER DEFAULT 0,
expected_move_pct REAL,
skip_reason TEXT DEFAULT 'no_profile',
skip_detail TEXT,
asset_class TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now'))
)""")
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_skipped_date ON skipped_trades(created_at DESC)")
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)")
c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)")
@@ -1014,6 +1033,17 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes
if matched is None:
skipped_no_profile += 1
_log.debug(f"[TradeLog] SKIP {underlying} score={eff_score} gain={exp_move:.0f}% — no profile match")
_trade_ac = trade.get("asset_class") or sp.get("asset_class") or _orig.get("asset_class") or ""
try:
log_skipped_trade(
run_id=run_id, pattern_id=pid, pattern_name=pattern_name,
underlying=underlying, strategy=strategy, score=eff_score,
expected_move_pct=exp_move,
skip_detail=f"profiles checked: {len(profiles)}, best: score>={eff_score} gain>={exp_move:.0f}%",
asset_class=_trade_ac,
)
except Exception:
pass
continue
ev_gross, ev_net, trade_score = _compute_trade_score(eff_score, exp_move)
@@ -1205,6 +1235,35 @@ def get_closed_trades(days: int = 180) -> List[Dict[str, Any]]:
return [dict(r) for r in rows]
def get_skipped_trades(days: int = 30) -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM skipped_trades
WHERE created_at >= date('now', ?)
ORDER BY created_at DESC, score DESC""",
(f"-{days} days",)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def log_skipped_trade(run_id: str, pattern_id: str, pattern_name: str,
underlying: str, strategy: str, score: int,
expected_move_pct: float, skip_reason: str = "no_profile",
skip_detail: str = "", asset_class: str = "") -> None:
conn = get_conn()
conn.execute(
"""INSERT INTO skipped_trades
(run_id, pattern_id, pattern_name, underlying, strategy, score,
expected_move_pct, skip_reason, skip_detail, asset_class)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(run_id, pattern_id, pattern_name, underlying, strategy, score,
expected_move_pct, skip_reason, skip_detail, asset_class)
)
conn.commit()
conn.close()
def close_trade(trade_id: int, close_price: float, pnl_realized: float,
close_reason: str, close_note: str = "") -> bool:
conn = get_conn()

View File

@@ -472,6 +472,13 @@ export const useTradeCheck = () =>
api.post('/journal/trade-check', body).then(r => r.data),
})
export const useSkippedTrades = (days = 30) =>
useQuery({
queryKey: ['journal-skipped', days],
queryFn: () => api.get(`/journal/skipped-trades?days=${days}`).then(r => r.data),
staleTime: 60_000,
})
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>

View File

@@ -4,8 +4,10 @@ import {
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
useSimPortfolioRisk, useCycleStatus, useKnowledgeState,
} from '../hooks/useApi'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert, LayoutGrid, List, Terminal } from 'lucide-react'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert, LayoutGrid, List, Terminal, ArrowUpRight } from 'lucide-react'
import { Link } from 'react-router-dom'
import clsx from 'clsx'
import type { Quote } from '../types'
import { format } from 'date-fns'
@@ -780,6 +782,9 @@ export default function Dashboard() {
const { data: tradeMtmData } = useTradeMtm(30)
const { data: riskProfilesData } = useRiskProfiles()
const { data: riskDashboard } = useRiskDashboard()
const { data: simRisk } = useSimPortfolioRisk()
const { data: cycleStatusData } = useCycleStatus()
const { data: knowledgeState } = useKnowledgeState()
const { mutate: scorePatterns, isPending: scoring } = useScorePatterns()
const { mutate: addPos } = useAddPosition()
@@ -1101,6 +1106,212 @@ export default function Dashboard() {
</div>
</div>
{/* ── Command Center: Résumé Opérationnel ── */}
<div className="grid grid-cols-4 gap-3">
{/* PnL Simulé */}
{(() => {
const trades: any[] = (tradeMtmData as any)?.trades ?? []
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
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📊 PnL Simulé</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold font-mono mt-1',
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
</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>
</div>
</Link>
)
})()}
{/* Risque Simulé */}
{(() => {
const risk = simRisk as any
const alertCount: number = risk?.alerts?.length ?? 0
const conflictCount: number = risk?.conflicts?.length ?? 0
const openCount: number = risk?.open_count ?? 0
return (
<Link to="/risk" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🛡 Risque Simulé</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold mt-1', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{openCount} positions
{conflictCount > 0 && <span className="text-red-400 ml-1">· {conflictCount} conflit{conflictCount > 1 ? 's' : ''}</span>}
</div>
</Link>
)
})()}
{/* Dernier Cycle */}
{(() => {
const last = (cycleStatusData as any)?.last_cycle
const ts = last?.ts
? new Date(last.ts.endsWith('Z') ? last.ts : last.ts + 'Z')
: null
const elapsed = ts ? Math.round((Date.now() - ts.getTime()) / 60_000) : null
const elapsedStr = elapsed === null ? '—'
: elapsed < 60 ? `${elapsed}min`
: elapsed < 1440 ? `${Math.round(elapsed / 60)}h`
: `${Math.round(elapsed / 1440)}j`
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🔄 Dernier Cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-2xl font-bold text-blue-400 mt-1">{elapsedStr}</div>
<div className="text-[10px] text-slate-500 mt-1">
{last
? `${last.patterns_added ?? 0} loggés · géo ${last.geo_score ?? '—'}`
: 'Aucun cycle enregistré'}
</div>
</Link>
)
})()}
{/* Régime Macro */}
{(() => {
const dom = macroInfo?.dominant
const colorClass = dom === 'growth' ? 'text-emerald-400'
: dom === 'stagflation' || dom === 'recession' ? 'text-red-400'
: dom === 'deflation' ? 'text-blue-300'
: 'text-slate-400'
return (
<Link to="/macro" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🌐 Régime Macro</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-lg font-bold mt-1', colorClass)}>
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1 line-clamp-1">
{macroInfo
? Object.entries(macroInfo.assetBias).slice(0, 2).map(([k, v]) => `${k}${v}`).join(' · ')
: 'Chargement...'}
</div>
</Link>
)
})()}
</div>
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
{/* Super Contexte IA */}
{(() => {
const state = (knowledgeState as any)?.state
const synthesis = state?.synthesis
const insights = (synthesis?.regime_insights?.length ?? 0)
+ (synthesis?.pattern_insights?.length ?? 0)
+ (synthesis?.recurring_mistakes?.length ?? 0)
const priority = synthesis?.strategic_priorities?.[0]
const excerpt = typeof priority === 'string' ? priority
: typeof state?.narrative === 'string' ? state.narrative.slice(0, 90)
: null
return (
<Link to="/super-contexte" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🧠 Super Contexte</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-sm font-bold text-purple-400 mt-1">
{state ? `${insights} insights actifs` : 'Aucune synthèse'}
</div>
<div className="text-[10px] text-slate-500 mt-1 line-clamp-2">
{excerpt ?? 'Lancer une synthèse dans Super Contexte'}
</div>
</Link>
)
})()}
{/* Signaux Géo */}
{(() => {
const topRisks: Array<[string, number]> = riskScore?.top_risks ?? []
return (
<Link to="/geo" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🌍 Signaux Géo</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold font-mono mt-1', gauge ? gauge.color : 'text-slate-500')}>
{riskScore?.score ?? '—'}
</div>
<div className="space-y-0.5 mt-1">
{topRisks.slice(0, 2).map(([cat, val]) => (
<div key={cat} className="flex justify-between text-[10px]">
<span className="text-slate-500 capitalize truncate">{cat.replace(/_/g, ' ')}</span>
<span className="text-slate-400 shrink-0 ml-1">{Math.round(val * 100)}%</span>
</div>
))}
</div>
</Link>
)
})()}
{/* Meilleur Pattern scoré */}
{(() => {
const best = [...allPatterns]
.map(p => ({ p, sp: scoreMap[p.id] }))
.filter(x => x.sp?.score != null)
.sort((a, b) => (b.sp.score ?? 0) - (a.sp.score ?? 0))[0]
const score = best?.sp?.score ?? null
const name = best?.p?.name ?? null
const ticker = best?.sp?.recommended_trade?.underlying ?? null
return (
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]"> Meilleur Pattern</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold font-mono mt-1', score !== null ? scoreColor(score) : 'text-slate-500')}>
{score ?? '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1 line-clamp-1">
{name ?? 'Aucun scoré'}{ticker ? ` · ${ticker}` : ''}
</div>
</Link>
)
})()}
{/* Patterns Actifs */}
{(() => {
const total = allPatterns.length
const scored = allPatterns.filter(p => scoreMap[p.id]).length
const unscored = total - scored
return (
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📋 Patterns Actifs</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-2xl font-bold text-slate-200 mt-1">{total}</div>
<div className="text-[10px] text-slate-500 mt-1">
<span className="text-emerald-500">{scored} scorés</span>
{unscored > 0 && <span className="text-slate-600"> · {unscored} à scorer</span>}
</div>
</Link>
)
})()}
</div>
{/* ── Trade ideas scorées par IA ── */}
<div>
{/* Toolbar */}

View File

@@ -1,7 +1,7 @@
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 } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, useSkippedTrades, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -1444,10 +1444,104 @@ const TABS = [
{ key: 'mtm', label: 'Ouverts', icon: TrendingUp },
{ key: 'closed', label: 'Fermés', icon: Lock },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
{ key: 'skipped', label: 'Non loggés', icon: XCircle },
] as const
function SkippedTradesSection({ days }: { days: number }) {
const { data, isLoading } = useSkippedTrades(days)
const trades: any[] = (data as any)?.trades ?? []
const ASSET_CLASS_COLORS: 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',
indices: 'bg-blue-900/30 text-blue-300 border-blue-700/30',
forex: 'bg-purple-900/30 text-purple-300 border-purple-700/30',
rates: 'bg-slate-800/60 text-slate-300 border-slate-600/30',
equities: 'bg-cyan-900/30 text-cyan-300 border-cyan-700/30',
}
if (isLoading) return <div className="card animate-pulse h-24" />
if (trades.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" />
Aucune suggestion non loggée sur {days}j tous les trades scorés passent au moins un profil de risque.
</div>
)
}
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
<span className="ml-2 text-slate-600 font-normal text-xs">
(score ou gain insuffisant pour tout profil actif)
</span>
</div>
<div className="flex gap-3 mb-4 flex-wrap">
{Object.entries(byReason).map(([reason, count]) => (
<div key={reason} className="badge badge-yellow text-[10px]">
{reason}: {count}
</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>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo'>('cycles')
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo' | 'skipped'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
@@ -1605,6 +1699,7 @@ export default function JournalDeBord() {
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'closed' && <ClosedTradesSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}
{tab === 'skipped' && <SkippedTradesSection days={days} />}
</div>
)
}