From 08651551db1dd22f9f8fbf6263cba74f439ca35c Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 19 Jun 2026 19:01:58 +0200 Subject: [PATCH] feat: cockpit command center + skipped trades journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/journal.py | 9 +- backend/services/database.py | 59 ++++++++ frontend/src/hooks/useApi.ts | 7 + frontend/src/pages/Dashboard.tsx | 213 ++++++++++++++++++++++++++- frontend/src/pages/JournalDeBord.tsx | 99 ++++++++++++- 5 files changed, 383 insertions(+), 4 deletions(-) diff --git a/backend/routers/journal.py b/backend/routers/journal.py index 0380de7..de6b06a 100644 --- a/backend/routers/journal.py +++ b/backend/routers/journal.py @@ -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.""" diff --git a/backend/services/database.py b/backend/services/database.py index bd85043..c74b2a9 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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() diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 0520957..e0e8921 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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 = () => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5893819..23b6172 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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() { + {/* ── Command Center: Résumé Opérationnel ── */} +
+ + {/* 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 ( + +
+ 📊 PnL Simulé + +
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'} +
+
+ {trades.length} trades · {winners}✓{' '} + {losers}✗ +
+ + ) + })()} + + {/* 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 ( + +
+ 🛡️ Risque Simulé + +
+
0 ? 'text-red-400' : 'text-emerald-400')}> + {alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'} +
+
+ {openCount} positions + {conflictCount > 0 && · {conflictCount} conflit{conflictCount > 1 ? 's' : ''}} +
+ + ) + })()} + + {/* 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 ( + +
+ 🔄 Dernier Cycle + +
+
{elapsedStr}
+
+ {last + ? `${last.patterns_added ?? 0} loggés · géo ${last.geo_score ?? '—'}` + : 'Aucun cycle enregistré'} +
+ + ) + })()} + + {/* 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 ( + +
+ 🌐 Régime Macro + +
+
+ {macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'} +
+
+ {macroInfo + ? Object.entries(macroInfo.assetBias).slice(0, 2).map(([k, v]) => `${k}→${v}`).join(' · ') + : 'Chargement...'} +
+ + ) + })()} +
+ + {/* ── Command Center: Intelligence & Contexte ── */} +
+ + {/* 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 ( + +
+ 🧠 Super Contexte + +
+
+ {state ? `${insights} insights actifs` : 'Aucune synthèse'} +
+
+ {excerpt ?? 'Lancer une synthèse dans Super Contexte'} +
+ + ) + })()} + + {/* Signaux Géo */} + {(() => { + const topRisks: Array<[string, number]> = riskScore?.top_risks ?? [] + return ( + +
+ 🌍 Signaux Géo + +
+
+ {riskScore?.score ?? '—'} +
+
+ {topRisks.slice(0, 2).map(([cat, val]) => ( +
+ {cat.replace(/_/g, ' ')} + {Math.round(val * 100)}% +
+ ))} +
+ + ) + })()} + + {/* 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 ( + +
+ ⭐ Meilleur Pattern + +
+
+ {score ?? '—'} +
+
+ {name ?? 'Aucun scoré'}{ticker ? ` · ${ticker}` : ''} +
+ + ) + })()} + + {/* Patterns Actifs */} + {(() => { + const total = allPatterns.length + const scored = allPatterns.filter(p => scoreMap[p.id]).length + const unscored = total - scored + return ( + +
+ 📋 Patterns Actifs + +
+
{total}
+
+ {scored} scorés + {unscored > 0 && · {unscored} à scorer} +
+ + ) + })()} +
+ {/* ── Trade ideas scorées par IA ── */}
{/* Toolbar */} diff --git a/frontend/src/pages/JournalDeBord.tsx b/frontend/src/pages/JournalDeBord.tsx index 0fc5d05..91ff6f9 100644 --- a/frontend/src/pages/JournalDeBord.tsx +++ b/frontend/src/pages/JournalDeBord.tsx @@ -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 = { + 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
+ + if (trades.length === 0) { + return ( +
+ + Aucune suggestion non loggée sur {days}j — tous les trades scorés passent au moins un profil de risque. +
+ ) + } + + const byReason = trades.reduce((acc: Record, t: any) => { + acc[t.skip_reason] = (acc[t.skip_reason] ?? 0) + 1 + return acc + }, {}) + + return ( +
+
+
+ + Trades suggérés non loggés — {trades.length} sur {days}j + + (score ou gain insuffisant pour tout profil actif) + +
+
+ {Object.entries(byReason).map(([reason, count]) => ( +
+ {reason}: {count} +
+ ))} +
+
+ + + + + + + + + + + + + + {trades.map((t: any) => ( + + + + + + + + + + ))} + +
PatternTickerStratégieScoreGain attenduClasseRaison du skip
{t.pattern_name || '—'}{t.underlying}{t.strategy || '—'}= 50 ? 'text-emerald-400' : t.score >= 25 ? 'text-yellow-400' : 'text-slate-600')}> + {t.score ?? '—'} + + {t.expected_move_pct != null ? `${t.expected_move_pct.toFixed(0)}%` : '—'} + + {t.asset_class ? ( + + {t.asset_class} + + ) : } + + {t.skip_detail || t.skip_reason} +
+
+
+
+ ) +} + 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' && } {tab === 'closed' && } {tab === 'geo' && } + {tab === 'skipped' && }
) }