From 18b3ae6f91941171d9d04ca5a584694a7dc0c838 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 18 Jun 2026 13:54:29 +0200 Subject: [PATCH] feat: IBKR ticket in Dashboard + Journal MtM (Strike, DTE, legs) Adds full Interactive Brokers order ticket to both the Dashboard cockpit and the Journal de Bord MtM expanded rows. Each ticket shows the underlying, computed strike in dollars, estimated expiry date (nearest Friday), per-leg BUY/SELL CALL/PUT breakdown, order type LIMIT, budget and target. Also adds Strike and DTE columns to the MtM table and persists strike_guidance + expiry_days_at_entry in trade_entry_prices. Co-Authored-By: Claude Sonnet 4.6 --- backend/services/database.py | 18 +- frontend/src/pages/Dashboard.tsx | 277 +++++++++++++++++++++------ frontend/src/pages/JournalDeBord.tsx | 150 ++++++++++++++- 3 files changed, 386 insertions(+), 59 deletions(-) diff --git a/backend/services/database.py b/backend/services/database.py index 7e2da92..98a546b 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -207,6 +207,8 @@ def init_db(): ("last_seen_at", "TEXT"), ("pnl_pct", "REAL"), ("capital_invested", "REAL"), + ("strike_guidance", "TEXT"), + ("expiry_days_at_entry", "INTEGER"), ]: try: c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}") @@ -1008,6 +1010,16 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes _orig.get("horizon_days") or 90 ) + strike_guidance = ( + trade.get("strike_guidance") or + sp.get("recommended_trade", {}).get("strike_guidance") or + None + ) + expiry_days_entry = int( + trade.get("expiry_days") or + sp.get("recommended_trade", {}).get("expiry_days") or + horizon + ) existing_row = conn.execute( "SELECT id FROM trade_entry_prices WHERE pattern_id=? AND underlying=? AND strategy=?", @@ -1025,12 +1037,14 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes (run_id, pattern_id, pattern_name, underlying, strategy, entry_price, entry_date, score_at_entry, latest_score, expected_move_pct, horizon_days, ev_at_entry, ev_net, - trade_score, matched_profile, last_seen_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( + trade_score, matched_profile, last_seen_at, + strike_guidance, expiry_days_at_entry) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( run_id, pid, pattern_name, ticker_key, strategy, entry_price, today, eff_score, eff_score, exp_move, horizon, ev_gross, ev_net, trade_score, matched, now_ts, + strike_guidance, expiry_days_entry, )) inserted_count += 1 _log.info(f"[TradeLog] NEW trade: pattern='{pattern_name}' {underlying} {strategy} score={eff_score} gain={exp_move:.0f}% profile='{matched}' price={entry_price}") diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 26051c1..5893819 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -5,7 +5,7 @@ import { useScorePatterns, useLastScores, useAllPatterns, useMacroRegime, usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard, } from '../hooks/useApi' -import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert, LayoutGrid, List } from 'lucide-react' +import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert, LayoutGrid, List, Terminal } from 'lucide-react' import clsx from 'clsx' import type { Quote } from '../types' import { format } from 'date-fns' @@ -165,11 +165,159 @@ const BIAS_DISPLAY: Record = { 'defensive':{ label: '⚠ Défensif', color: '#f59e0b' }, } +// ── IBKR Ticket ────────────────────────────────────────────────────────────── + +function computeStrikeDollars(price: number, guidance: string, strategy: string): number { + const g = guidance.toUpperCase() + const match = g.match(/(\d+)\s*%\s*OTM/) + if (!match) return price // ATM + const pct = parseInt(match[1]) / 100 + const isBearish = /put|bear/i.test(strategy) + const raw = isBearish ? price * (1 - pct) : price * (1 + pct) + // Round to sensible increment + const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5 + return Math.round(raw / inc) * inc +} + +function estimateExpiryDate(horizonDays: number): Date { + const d = new Date() + d.setDate(d.getDate() + horizonDays) + // Advance to nearest Friday (IBKR standard option expiry) + const dow = d.getDay() + if (dow !== 5) d.setDate(d.getDate() + ((5 - dow + 7) % 7)) + return d +} + +function buildLegs(strategy: string, strike: number | null, price: number | null) { + const s = strategy.toLowerCase() + const atm = price ? (() => { + const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5 + return Math.round(price / inc) * inc + })() : null + if (s.includes('bull call spread')) return [ + { action: 'BUY', type: 'CALL', strike: atm }, + { action: 'SELL', type: 'CALL', strike }, + ] + if (s.includes('bear put spread')) return [ + { action: 'BUY', type: 'PUT', strike: atm }, + { action: 'SELL', type: 'PUT', strike }, + ] + if (s.includes('straddle')) return [ + { action: 'BUY', type: 'CALL', strike: atm ?? strike }, + { action: 'BUY', type: 'PUT', strike: atm ?? strike }, + ] + if (s.includes('strangle')) return [ + { action: 'BUY', type: 'CALL', strike: price ? Math.round(price * 1.05) : strike }, + { action: 'BUY', type: 'PUT', strike: price ? Math.round(price * 0.95) : strike }, + ] + if (s.includes('call')) return [{ action: 'BUY', type: 'CALL', strike }] + if (s.includes('put')) return [{ action: 'BUY', type: 'PUT', strike }] + return [{ action: 'BUY', type: strategy.toUpperCase(), strike }] +} + +function IBKRTicket({ strategy, underlying, strikeGuidance, expiryDays, entryPrice, maxLoss, targetGain, timing, invalidation }: { + strategy: string + underlying: string + strikeGuidance?: string | null + expiryDays?: number | null + entryPrice?: number | null + maxLoss?: number | null + targetGain?: number | null + timing?: string | null + invalidation?: string | null +}) { + const strike = entryPrice && strikeGuidance + ? computeStrikeDollars(entryPrice, strikeGuidance, strategy) + : (entryPrice ?? null) + const expiryDate = expiryDays ? estimateExpiryDate(expiryDays) : null + const legs = buildLegs(strategy, strike, entryPrice ?? null) + const fmtStrike = (s: number | null | undefined) => + s == null ? '—' : s >= 100 ? `$${s.toFixed(0)}` : `$${s.toFixed(2)}` + + return ( +
+
+ Ticket IBKR + {!entryPrice && Prix non disponible — vérifier dans IBKR} +
+ + {/* Header: ticker + type */} +
+
+
Sous-jacent
+
{underlying}
+
Security Type: OPT
+
+
+
Stratégie
+
{strategy}
+ {entryPrice &&
Sous-jacent: ${entryPrice.toFixed(2)}
} +
+
+
Expiration
+
+ {expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'} +
+
{expiryDays ? `${expiryDays}j DTE` : ''}
+
+
+ + {/* Legs */} +
+ {legs.map((leg, i) => ( +
+ {leg.action} + 1 contrat + {leg.type} + Strike + {fmtStrike(leg.strike)} + {leg.strike === null && strikeGuidance && ( + ({strikeGuidance}) + )} +
+ ))} +
+ + {/* Order params */} +
+
+
Type d'ordre
+
LIMIT
+
Prix = prime dans IBKR
+
+
+
Budget max
+
{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000€'}
+
Perte max estimée
+
+
+
Cible
+
{targetGain ? `+${targetGain.toFixed(0)}€` : '—'}
+
+
+ + {/* Timing / invalidation */} + {timing && ( +
+ + {timing} +
+ )} + {invalidation && ( +
+ + {invalidation} +
+ )} +
+ ) +} + function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { item: TradeItem onAdd: (item: TradeItem) => void macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record } | null - addedInfo?: { entry_date: string; expiry_days?: number } | null + addedInfo?: { entry_date: string; expiry_days?: number; entry_price?: number; strike_guidance?: string } | null profiles?: any[] }) { const [expanded, setExpanded] = useState(false) @@ -392,7 +540,7 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: { item: TradeItem onAdd: (item: TradeItem) => void macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record } | null - addedInfo?: { entry_date: string; expiry_days?: number } | null + addedInfo?: { entry_date: string; expiry_days?: number; entry_price?: number; strike_guidance?: string } | null profiles?: any[] rank: number }) { @@ -529,56 +677,72 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: { {expanded && ( -
- {/* Col 1: score justification */} -
- {scoreInfo?.key_catalyst && ( -
- 🔑 -

{scoreInfo.key_catalyst}

-
- )} - {rankRationale &&

{rankRationale}

} - {scoreInfo?.summary &&

{scoreInfo.summary}

} - {scoreInfo?.buckets?.length > 0 && ( - - )} -
- {/* Col 2: actions + contra + timing */} -
- {scoreInfo?.contra_signals?.length > 0 && ( -
-
Contra signals
- {scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => ( -
{cs.title ?? cs}
- ))} -
- )} - {macroInfo && macroInfo.dominant !== 'incertain' && ( -
- {macroInfo.emoji} {macroInfo.label} - · - {bd.label} -
- )} - {addedInfo && ( -
- - Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })} -
- )} - +
+
+ {/* Col 1: score justification */} +
+ {scoreInfo?.key_catalyst && ( +
+ 🔑 +

{scoreInfo.key_catalyst}

+
+ )} + {rankRationale &&

{rankRationale}

} + {scoreInfo?.summary &&

{scoreInfo.summary}

} + {scoreInfo?.buckets?.length > 0 && ( + + )} +
+ {/* Col 2: contra + macro + add button */} +
+ {scoreInfo?.contra_signals?.length > 0 && ( +
+
Contra signals
+ {scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => ( +
{cs.title ?? cs}
+ ))} +
+ )} + {macroInfo && macroInfo.dominant !== 'incertain' && ( +
+ {macroInfo.emoji} {macroInfo.label} + · + {bd.label} +
+ )} + {addedInfo && ( +
+ + Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })} +
+ )} + +
+ {/* IBKR Ticket — full width below the analysis */} + {trade.strategy && trade.underlying && ( + + )}
@@ -653,13 +817,16 @@ export default function Dashboard() { // Fallback map from cycle-auto-logged trades (trade_entry_prices), keyed by pattern_id const mtmMap = useMemo(() => { - const map: Record = {} + const map: Record = {} for (const t of ((tradeMtmData as any)?.trades ?? [])) { const pid = t.pattern_id as string if (!pid) continue const entry_date = (t.entry_date as string) ?? '' - const expiry_days = (t.horizon_days as number) ?? undefined - if (!map[pid] || entry_date > map[pid].entry_date) map[pid] = { entry_date, expiry_days } + const expiry_days = (t.expiry_days_at_entry ?? t.horizon_days) as number | undefined + const entry_price = (t.entry_price as number) ?? undefined + const strike_guidance = (t.strike_guidance as string) ?? undefined + if (!map[pid] || entry_date > map[pid].entry_date) + map[pid] = { entry_date, expiry_days, entry_price, strike_guidance } } return map }, [tradeMtmData]) diff --git a/frontend/src/pages/JournalDeBord.tsx b/frontend/src/pages/JournalDeBord.tsx index 0d3d7f8..14c9380 100644 --- a/frontend/src/pages/JournalDeBord.tsx +++ b/frontend/src/pages/JournalDeBord.tsx @@ -1,8 +1,10 @@ import { useState, useEffect, useRef, Fragment } from 'react' -import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react' +import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal } from 'lucide-react' import clsx from 'clsx' import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' +import { format } from 'date-fns' +import { fr } from 'date-fns/locale' const SCENARIO_META: Record = { goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' }, @@ -99,6 +101,133 @@ function KellyCell({ patternId, capital = 10000 }: { patternId: string; capital? ) } +// ── IBKR Ticket helpers ─────────────────────────────────────────────────────── + +function computeStrikeDollars(price: number, guidance: string, strategy: string): number { + const g = guidance.toUpperCase() + const match = g.match(/(\d+)\s*%\s*OTM/) + if (!match) return price + const pct = parseInt(match[1]) / 100 + const isBearish = /put|bear/i.test(strategy) + const raw = isBearish ? price * (1 - pct) : price * (1 + pct) + const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5 + return Math.round(raw / inc) * inc +} + +function estimateExpiryDate(horizonDays: number): Date { + const d = new Date() + d.setDate(d.getDate() + horizonDays) + const dow = d.getDay() + if (dow !== 5) d.setDate(d.getDate() + ((5 - dow + 7) % 7)) + return d +} + +function buildLegs(strategy: string, strike: number | null, price: number | null) { + const s = strategy.toLowerCase() + const atm = price ? (() => { + const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5 + return Math.round(price / inc) * inc + })() : null + if (s.includes('bull call spread')) return [ + { action: 'BUY', type: 'CALL', strike: atm }, + { action: 'SELL', type: 'CALL', strike }, + ] + if (s.includes('bear put spread')) return [ + { action: 'BUY', type: 'PUT', strike: atm }, + { action: 'SELL', type: 'PUT', strike }, + ] + if (s.includes('straddle')) return [ + { action: 'BUY', type: 'CALL', strike: atm ?? strike }, + { action: 'BUY', type: 'PUT', strike: atm ?? strike }, + ] + if (s.includes('strangle')) return [ + { action: 'BUY', type: 'CALL', strike: price ? Math.round(price * 1.05) : strike }, + { action: 'BUY', type: 'PUT', strike: price ? Math.round(price * 0.95) : strike }, + ] + if (s.includes('call')) return [{ action: 'BUY', type: 'CALL', strike }] + if (s.includes('put')) return [{ action: 'BUY', type: 'PUT', strike }] + return [{ action: 'BUY', type: strategy.toUpperCase(), strike }] +} + +function IBKRTicket({ strategy, underlying, strikeGuidance, expiryDays, entryPrice, maxLoss, targetGain }: { + strategy: string + underlying: string + strikeGuidance?: string | null + expiryDays?: number | null + entryPrice?: number | null + maxLoss?: number | null + targetGain?: number | null +}) { + const strike = entryPrice && strikeGuidance + ? computeStrikeDollars(entryPrice, strikeGuidance, strategy) + : (entryPrice ?? null) + const expiryDate = expiryDays ? estimateExpiryDate(expiryDays) : null + const legs = buildLegs(strategy, strike, entryPrice ?? null) + const fmtStrike = (s: number | null | undefined) => + s == null ? '—' : s >= 100 ? `$${s.toFixed(0)}` : `$${s.toFixed(2)}` + + return ( +
+
+ Ticket IBKR + {!entryPrice && Prix non disponible — vérifier dans IBKR} +
+ +
+
+
Sous-jacent
+
{underlying}
+
Security Type: OPT
+
+
+
Stratégie
+
{strategy}
+ {entryPrice &&
Sous-jacent: ${entryPrice.toFixed(2)}
} +
+
+
Expiration
+
+ {expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'} +
+
{expiryDays ? `${expiryDays}j DTE` : ''}
+
+
+ +
+ {legs.map((leg, i) => ( +
+ {leg.action} + 1 contrat + {leg.type} + Strike + {fmtStrike(leg.strike)} + {leg.strike === null && strikeGuidance && ( + ({strikeGuidance}) + )} +
+ ))} +
+ +
+
+
Type d'ordre
+
LIMIT
+
Prix = prime dans IBKR
+
+
+
Budget max
+
{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000€'}
+
Perte max estimée
+
+
+
Cible
+
{targetGain ? `+${targetGain.toFixed(0)}€` : '—'}
+
+
+
+ ) +} + function MacroHistorySection({ days }: { days: number }) { const { data, isLoading, refetch, isFetching } = useMacroHistory(days) const history: any[] = (data as any)?.history ?? [] @@ -424,6 +553,8 @@ function TradeMtmSection({ days }: { days: number }) { Pattern Profil Stratégie + Strike + DTE Ticker Score Trade Score @@ -464,6 +595,12 @@ function TradeMtmSection({ days }: { days: number }) { {t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'} + + {t.strike_guidance ?? '—'} + + + {t.expiry_days_at_entry != null ? `${t.expiry_days_at_entry}j` : t.horizon_days != null ? `${t.horizon_days}j` : '—'} + {t.underlying} @@ -554,7 +691,16 @@ function TradeMtmSection({ days }: { days: number }) { {isExpanded && ( - + + setSelectedTradeId(null)} />