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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-18 13:54:29 +02:00
parent 05a475fb04
commit 18b3ae6f91
3 changed files with 386 additions and 59 deletions

View File

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

View File

@@ -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<string, { label: string; color: string }> = {
'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 (
<div className="bg-blue-950/20 border border-blue-700/30 rounded p-3 space-y-2.5">
<div className="text-[10px] font-bold text-blue-300 uppercase tracking-wide flex items-center gap-1.5">
<Terminal className="w-3 h-3" /> Ticket IBKR
{!entryPrice && <span className="ml-auto text-slate-600 font-normal normal-case">Prix non disponible vérifier dans IBKR</span>}
</div>
{/* Header: ticker + type */}
<div className="grid grid-cols-3 gap-3 text-[10px]">
<div>
<div className="text-slate-600 mb-0.5">Sous-jacent</div>
<div className="text-white font-mono font-bold text-sm">{underlying}</div>
<div className="text-slate-600 text-[9px]">Security Type: OPT</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Stratégie</div>
<div className="text-slate-200 font-semibold">{strategy}</div>
{entryPrice && <div className="text-slate-600 text-[9px]">Sous-jacent: ${entryPrice.toFixed(2)}</div>}
</div>
<div>
<div className="text-slate-600 mb-0.5">Expiration</div>
<div className="text-slate-200 font-mono">
{expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'}
</div>
<div className="text-slate-600 text-[9px]">{expiryDays ? `${expiryDays}j DTE` : ''}</div>
</div>
</div>
{/* Legs */}
<div className="space-y-1">
{legs.map((leg, i) => (
<div key={i} className="flex items-center gap-2 bg-slate-900/70 rounded px-2.5 py-1.5 font-mono text-[10px]">
<span className={clsx('w-8 font-bold', leg.action === 'BUY' ? 'text-emerald-400' : 'text-red-400')}>{leg.action}</span>
<span className="text-slate-400">1 contrat</span>
<span className={clsx('font-bold w-10 text-center', leg.type === 'CALL' ? 'text-blue-300' : leg.type === 'PUT' ? 'text-orange-300' : 'text-slate-300')}>{leg.type}</span>
<span className="text-slate-600">Strike</span>
<span className="text-yellow-300 font-bold">{fmtStrike(leg.strike)}</span>
{leg.strike === null && strikeGuidance && (
<span className="text-slate-500">({strikeGuidance})</span>
)}
</div>
))}
</div>
{/* Order params */}
<div className="grid grid-cols-3 gap-3 text-[10px]">
<div>
<div className="text-slate-600 mb-0.5">Type d'ordre</div>
<div className="text-slate-300 font-semibold">LIMIT</div>
<div className="text-slate-600 text-[9px]">Prix = prime dans IBKR</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Budget max</div>
<div className="text-slate-300 font-mono">{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000'}</div>
<div className="text-slate-600 text-[9px]">Perte max estimée</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Cible</div>
<div className="text-emerald-400 font-mono">{targetGain ? `+${targetGain.toFixed(0)}€` : ''}</div>
</div>
</div>
{/* Timing / invalidation */}
{timing && (
<div className="flex gap-1.5 text-[10px] bg-yellow-900/10 border border-yellow-700/20 rounded px-2 py-1.5">
<span className="text-yellow-400 shrink-0">⏱</span>
<span className="text-yellow-200/70 leading-snug">{timing}</span>
</div>
)}
{invalidation && (
<div className="flex gap-1.5 text-[10px] bg-red-900/10 border border-red-700/20 rounded px-2 py-1.5">
<span className="text-red-400 shrink-0">✗</span>
<span className="text-red-200/70 leading-snug">{invalidation}</span>
</div>
)}
</div>
)
}
function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
item: TradeItem
onAdd: (item: TradeItem) => void
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | 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<string, string> } | 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,7 +677,8 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
{expanded && (
<tr className="bg-dark-900/60">
<td colSpan={13} className="px-0 py-0 border-b border-blue-700/20">
<div className="p-4 grid grid-cols-3 gap-4 text-xs">
<div className="p-4 space-y-3 text-xs">
<div className="grid grid-cols-3 gap-4">
{/* Col 1: score justification */}
<div className="col-span-2">
{scoreInfo?.key_catalyst && (
@@ -544,7 +693,7 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={undefined} />
)}
</div>
{/* Col 2: actions + contra + timing */}
{/* Col 2: contra + macro + add button */}
<div className="space-y-3">
{scoreInfo?.contra_signals?.length > 0 && (
<div className="bg-orange-900/10 border border-orange-700/20 rounded p-2 space-y-1">
@@ -580,6 +729,21 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
</button>
</div>
</div>
{/* IBKR Ticket — full width below the analysis */}
{trade.strategy && trade.underlying && (
<IBKRTicket
strategy={trade.strategy}
underlying={trade.underlying}
strikeGuidance={trade.strike_guidance ?? scoreInfo?.recommended_trade?.strike_guidance ?? addedInfo?.strike_guidance}
expiryDays={scoreInfo?.recommended_trade?.expiry_days ?? trade.expiry_days ?? trade.horizon_days ?? addedInfo?.expiry_days}
entryPrice={addedInfo?.entry_price ?? null}
maxLoss={trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur}
targetGain={trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur}
timing={scoreInfo?.recommended_trade?.timing_note}
invalidation={scoreInfo?.recommended_trade?.invalidation}
/>
)}
</div>
</td>
</tr>
)}
@@ -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<string, { entry_date: string; expiry_days?: number }> = {}
const map: Record<string, { entry_date: string; expiry_days?: number; entry_price?: number; strike_guidance?: string }> = {}
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])

View File

@@ -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<string, { label: string; color: string; emoji: string }> = {
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 (
<div className="bg-blue-950/20 border border-blue-700/30 rounded p-3 space-y-2.5">
<div className="text-[10px] font-bold text-blue-300 uppercase tracking-wide flex items-center gap-1.5">
<Terminal className="w-3 h-3" /> Ticket IBKR
{!entryPrice && <span className="ml-auto text-slate-600 font-normal normal-case">Prix non disponible vérifier dans IBKR</span>}
</div>
<div className="grid grid-cols-3 gap-3 text-[10px]">
<div>
<div className="text-slate-600 mb-0.5">Sous-jacent</div>
<div className="text-white font-mono font-bold text-sm">{underlying}</div>
<div className="text-slate-600 text-[9px]">Security Type: OPT</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Stratégie</div>
<div className="text-slate-200 font-semibold">{strategy}</div>
{entryPrice && <div className="text-slate-600 text-[9px]">Sous-jacent: ${entryPrice.toFixed(2)}</div>}
</div>
<div>
<div className="text-slate-600 mb-0.5">Expiration</div>
<div className="text-slate-200 font-mono">
{expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'}
</div>
<div className="text-slate-600 text-[9px]">{expiryDays ? `${expiryDays}j DTE` : ''}</div>
</div>
</div>
<div className="space-y-1">
{legs.map((leg, i) => (
<div key={i} className="flex items-center gap-2 bg-slate-900/70 rounded px-2.5 py-1.5 font-mono text-[10px]">
<span className={clsx('w-8 font-bold', leg.action === 'BUY' ? 'text-emerald-400' : 'text-red-400')}>{leg.action}</span>
<span className="text-slate-400">1 contrat</span>
<span className={clsx('font-bold w-10 text-center', leg.type === 'CALL' ? 'text-blue-300' : leg.type === 'PUT' ? 'text-orange-300' : 'text-slate-300')}>{leg.type}</span>
<span className="text-slate-600">Strike</span>
<span className="text-yellow-300 font-bold">{fmtStrike(leg.strike)}</span>
{leg.strike === null && strikeGuidance && (
<span className="text-slate-500">({strikeGuidance})</span>
)}
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3 text-[10px]">
<div>
<div className="text-slate-600 mb-0.5">Type d'ordre</div>
<div className="text-slate-300 font-semibold">LIMIT</div>
<div className="text-slate-600 text-[9px]">Prix = prime dans IBKR</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Budget max</div>
<div className="text-slate-300 font-mono">{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000'}</div>
<div className="text-slate-600 text-[9px]">Perte max estimée</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Cible</div>
<div className="text-emerald-400 font-mono">{targetGain ? `+${targetGain.toFixed(0)}€` : ''}</div>
</div>
</div>
</div>
)
}
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 }) {
<th className="text-left px-3 py-2 font-medium">Pattern</th>
<th className="text-left px-3 py-2 font-medium">Profil</th>
<th className="text-left px-3 py-2 font-medium">Stratégie</th>
<th className="text-right px-3 py-2 font-medium">Strike</th>
<th className="text-right px-3 py-2 font-medium">DTE</th>
<th className="text-left px-3 py-2 font-medium">Ticker</th>
<th className="text-right px-3 py-2 font-medium">Score</th>
<th className="text-right px-3 py-2 font-medium">Trade Score</th>
@@ -464,6 +595,12 @@ function TradeMtmSection({ days }: { days: number }) {
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || ''}
</span>
</td>
<td className="px-3 py-2 text-right font-mono text-[11px] text-slate-400">
{t.strike_guidance ?? ''}
</td>
<td className="px-3 py-2 text-right font-mono text-[11px] text-slate-400">
{t.expiry_days_at_entry != null ? `${t.expiry_days_at_entry}j` : t.horizon_days != null ? `${t.horizon_days}j` : ''}
</td>
<td className="px-3 py-2 font-mono text-slate-300">{t.underlying}</td>
<td className="px-3 py-2 text-right">
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
@@ -554,7 +691,16 @@ function TradeMtmSection({ days }: { days: number }) {
</tr>
{isExpanded && (
<tr className="bg-dark-900/60">
<td colSpan={16} className="px-0 py-0 border-b border-blue-700/20">
<td colSpan={18} className="px-4 py-3 border-b border-blue-700/20 space-y-3">
<IBKRTicket
strategy={t.strategy ?? ''}
underlying={t.underlying}
strikeGuidance={t.strike_guidance}
expiryDays={t.expiry_days_at_entry ?? t.horizon_days}
entryPrice={t.entry_price}
maxLoss={t.max_loss_eur}
targetGain={t.target_gain_eur}
/>
<PostmortemPanel tradeId={t.id} onClose={() => setSelectedTradeId(null)} />
</td>
</tr>