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:
@@ -207,6 +207,8 @@ def init_db():
|
|||||||
("last_seen_at", "TEXT"),
|
("last_seen_at", "TEXT"),
|
||||||
("pnl_pct", "REAL"),
|
("pnl_pct", "REAL"),
|
||||||
("capital_invested", "REAL"),
|
("capital_invested", "REAL"),
|
||||||
|
("strike_guidance", "TEXT"),
|
||||||
|
("expiry_days_at_entry", "INTEGER"),
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}")
|
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
|
_orig.get("horizon_days") or
|
||||||
90
|
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(
|
existing_row = conn.execute(
|
||||||
"SELECT id FROM trade_entry_prices WHERE pattern_id=? AND underlying=? AND strategy=?",
|
"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,
|
(run_id, pattern_id, pattern_name, underlying, strategy,
|
||||||
entry_price, entry_date, score_at_entry, latest_score,
|
entry_price, entry_date, score_at_entry, latest_score,
|
||||||
expected_move_pct, horizon_days, ev_at_entry, ev_net,
|
expected_move_pct, horizon_days, ev_at_entry, ev_net,
|
||||||
trade_score, matched_profile, last_seen_at)
|
trade_score, matched_profile, last_seen_at,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
|
strike_guidance, expiry_days_at_entry)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
|
||||||
run_id, pid, pattern_name, ticker_key, strategy,
|
run_id, pid, pattern_name, ticker_key, strategy,
|
||||||
entry_price, today, eff_score, eff_score,
|
entry_price, today, eff_score, eff_score,
|
||||||
exp_move, horizon, ev_gross, ev_net,
|
exp_move, horizon, ev_gross, ev_net,
|
||||||
trade_score, matched, now_ts,
|
trade_score, matched, now_ts,
|
||||||
|
strike_guidance, expiry_days_entry,
|
||||||
))
|
))
|
||||||
inserted_count += 1
|
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}")
|
_log.info(f"[TradeLog] NEW trade: pattern='{pattern_name}' {underlying} {strategy} score={eff_score} gain={exp_move:.0f}% profile='{matched}' price={entry_price}")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
||||||
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
|
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
|
||||||
} from '../hooks/useApi'
|
} 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 clsx from 'clsx'
|
||||||
import type { Quote } from '../types'
|
import type { Quote } from '../types'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
@@ -165,11 +165,159 @@ const BIAS_DISPLAY: Record<string, { label: string; color: string }> = {
|
|||||||
'defensive':{ label: '⚠ Défensif', color: '#f59e0b' },
|
'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 }: {
|
function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
|
||||||
item: TradeItem
|
item: TradeItem
|
||||||
onAdd: (item: TradeItem) => void
|
onAdd: (item: TradeItem) => void
|
||||||
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
|
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[]
|
profiles?: any[]
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
@@ -392,7 +540,7 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
|
|||||||
item: TradeItem
|
item: TradeItem
|
||||||
onAdd: (item: TradeItem) => void
|
onAdd: (item: TradeItem) => void
|
||||||
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
|
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[]
|
profiles?: any[]
|
||||||
rank: number
|
rank: number
|
||||||
}) {
|
}) {
|
||||||
@@ -529,56 +677,72 @@ function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<tr className="bg-dark-900/60">
|
<tr className="bg-dark-900/60">
|
||||||
<td colSpan={13} className="px-0 py-0 border-b border-blue-700/20">
|
<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">
|
||||||
{/* Col 1: score justification */}
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="col-span-2">
|
{/* Col 1: score justification */}
|
||||||
{scoreInfo?.key_catalyst && (
|
<div className="col-span-2">
|
||||||
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5 mb-3">
|
{scoreInfo?.key_catalyst && (
|
||||||
<span className="text-yellow-400 shrink-0">🔑</span>
|
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5 mb-3">
|
||||||
<p className="text-yellow-200/80 leading-snug">{scoreInfo.key_catalyst}</p>
|
<span className="text-yellow-400 shrink-0">🔑</span>
|
||||||
</div>
|
<p className="text-yellow-200/80 leading-snug">{scoreInfo.key_catalyst}</p>
|
||||||
)}
|
</div>
|
||||||
{rankRationale && <p className="text-slate-500 italic mb-2">{rankRationale}</p>}
|
)}
|
||||||
{scoreInfo?.summary && <p className="text-slate-400 mb-3">{scoreInfo.summary}</p>}
|
{rankRationale && <p className="text-slate-500 italic mb-2">{rankRationale}</p>}
|
||||||
{scoreInfo?.buckets?.length > 0 && (
|
{scoreInfo?.summary && <p className="text-slate-400 mb-3">{scoreInfo.summary}</p>}
|
||||||
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={undefined} />
|
{scoreInfo?.buckets?.length > 0 && (
|
||||||
)}
|
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={undefined} />
|
||||||
</div>
|
)}
|
||||||
{/* Col 2: actions + contra + timing */}
|
</div>
|
||||||
<div className="space-y-3">
|
{/* Col 2: contra + macro + add button */}
|
||||||
{scoreInfo?.contra_signals?.length > 0 && (
|
<div className="space-y-3">
|
||||||
<div className="bg-orange-900/10 border border-orange-700/20 rounded p-2 space-y-1">
|
{scoreInfo?.contra_signals?.length > 0 && (
|
||||||
<div className="text-orange-400 font-semibold uppercase text-[9px] tracking-wide">Contra signals</div>
|
<div className="bg-orange-900/10 border border-orange-700/20 rounded p-2 space-y-1">
|
||||||
{scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => (
|
<div className="text-orange-400 font-semibold uppercase text-[9px] tracking-wide">Contra signals</div>
|
||||||
<div key={i} className="text-orange-300/70 text-[10px] leading-snug">{cs.title ?? cs}</div>
|
{scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => (
|
||||||
))}
|
<div key={i} className="text-orange-300/70 text-[10px] leading-snug">{cs.title ?? cs}</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
{macroInfo && macroInfo.dominant !== 'incertain' && (
|
)}
|
||||||
<div className="text-[11px]">
|
{macroInfo && macroInfo.dominant !== 'incertain' && (
|
||||||
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
|
<div className="text-[11px]">
|
||||||
<span className="text-slate-600 mx-1">·</span>
|
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
|
||||||
<span style={{ color: bd.color }}>{bd.label}</span>
|
<span className="text-slate-600 mx-1">·</span>
|
||||||
</div>
|
<span style={{ color: bd.color }}>{bd.label}</span>
|
||||||
)}
|
</div>
|
||||||
{addedInfo && (
|
)}
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
|
{addedInfo && (
|
||||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
|
||||||
Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
</div>
|
Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
|
||||||
)}
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={e => { e.stopPropagation(); onAdd(item) }}
|
<button
|
||||||
className={clsx(
|
onClick={e => { e.stopPropagation(); onAdd(item) }}
|
||||||
'w-full flex items-center justify-center gap-1 text-xs rounded py-1.5 transition-all border',
|
className={clsx(
|
||||||
addedInfo
|
'w-full flex items-center justify-center gap-1 text-xs rounded py-1.5 transition-all border',
|
||||||
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
|
addedInfo
|
||||||
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
|
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
|
||||||
)}>
|
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
|
||||||
<Plus className="w-3 h-3" />
|
)}>
|
||||||
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -653,13 +817,16 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
// Fallback map from cycle-auto-logged trades (trade_entry_prices), keyed by pattern_id
|
// Fallback map from cycle-auto-logged trades (trade_entry_prices), keyed by pattern_id
|
||||||
const mtmMap = useMemo(() => {
|
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 ?? [])) {
|
for (const t of ((tradeMtmData as any)?.trades ?? [])) {
|
||||||
const pid = t.pattern_id as string
|
const pid = t.pattern_id as string
|
||||||
if (!pid) continue
|
if (!pid) continue
|
||||||
const entry_date = (t.entry_date as string) ?? ''
|
const entry_date = (t.entry_date as string) ?? ''
|
||||||
const expiry_days = (t.horizon_days as number) ?? undefined
|
const expiry_days = (t.expiry_days_at_entry ?? t.horizon_days) as number | undefined
|
||||||
if (!map[pid] || entry_date > map[pid].entry_date) map[pid] = { entry_date, expiry_days }
|
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
|
return map
|
||||||
}, [tradeMtmData])
|
}, [tradeMtmData])
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, Fragment } from 'react'
|
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 clsx from 'clsx'
|
||||||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
|
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 }> = {
|
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
|
||||||
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
|
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 }) {
|
function MacroHistorySection({ days }: { days: number }) {
|
||||||
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
|
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
|
||||||
const history: any[] = (data as any)?.history ?? []
|
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">Pattern</th>
|
||||||
<th className="text-left px-3 py-2 font-medium">Profil</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-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-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">Score</th>
|
||||||
<th className="text-right px-3 py-2 font-medium">Trade 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 || '—'}
|
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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 font-mono text-slate-300">{t.underlying}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
|
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
|
||||||
@@ -554,7 +691,16 @@ function TradeMtmSection({ days }: { days: number }) {
|
|||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr className="bg-dark-900/60">
|
<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)} />
|
<PostmortemPanel tradeId={t.id} onClose={() => setSelectedTradeId(null)} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user