import { useState, useMemo, useEffect, Fragment } from 'react' import { useAllPatterns, useLastScores, useScorePatterns, useAiStatus, usePortfolioPositions, useTradeMtm, useRiskProfiles, useMacroRegime, useAddPosition, useConfig, useAiTradeProposals, useConfirmAiTradeProposal, useRejectAiTradeProposal, } from '../hooks/useApi' import { Target, Brain, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, LayoutGrid, List, Terminal, Bot, Check, X as XIcon, } from 'lucide-react' import clsx from 'clsx' import { format } from 'date-fns' import { fr } from 'date-fns/locale' // ── Scoring utilities (re-exported for Dashboard use) ───────────────────────── export const scoreColor = (s: number) => { if (s >= 70) return 'text-emerald-400' if (s >= 50) return 'text-yellow-400' return 'text-slate-400' } export const scoreBg = (s: number) => { if (s >= 70) return 'bg-emerald-500' if (s >= 50) return 'bg-yellow-500' return 'bg-slate-600' } // ── Constants ───────────────────────────────────────────────────────────────── const BUCKET_ICONS: Record = { actualites: '📰', calendrier: '📅', prix: '📈', rr: '⚖️', geo: '🌍', eco: '🌐', flux: '📡', banques: '🏦', macro_cal: '📋', taux: '📉', energie: '⛽', forex_sig: '💱', actions: '📊', vix: '⚡', asymetrie: '⚖️', timing_rr: '⏱️', } export const CATEGORIES = [ { key: 'all', label: 'All' }, { key: 'energy', label: '⛽ Energy' }, { key: 'metals', label: '🥇 Metals' }, { key: 'agriculture', label: '🌾 Agri' }, { key: 'indices', label: '📊 Indices' }, { key: 'equities', label: '📈 Equities' }, { key: 'forex', label: '💱 Forex' }, ] export const THEMATIC_CATEGORIES = [ { key: 'all', label: 'All' }, { key: 'géopolitique', label: '⚔️ Geopo' }, { key: 'macro_monétaire', label: '🏦 Macro' }, { key: 'technique', label: '📐 Technical' }, { key: 'commodités_supply',label: '🛢️ Supply' }, { key: 'risk_off', label: '🌩️ Risk-off' }, { key: 'flux_saisonnier', label: '📅 Flows' }, { key: 'géo_économique', label: '🌐 Geo-eco' }, { key: 'crédit_stress', label: '💳 Credit' }, ] const SIGNAL_DIR: Record = { bullish: { label: '▲', color: '#10b981' }, bearish: { label: '▼', color: '#ef4444' }, volatility: { label: '⟷', color: '#a78bfa' }, neutral: { label: '↔', color: '#94a3b8' }, } export interface TradeItem { trade: any patternName: string patternId: string assetClass: string category: string signalDirection: string score: number | null scoreInfo: any | null scoreDelta: number | null rankRationale: string | null expectedMovePct: number convictionScore: number | null convictionBonus: number convergenceCount: number convergencePartners: { name: string; category: string; score: number }[] } const BIAS_DISPLAY: Record = { 'bullish+': { label: '★★ Aligned', color: '#10b981' }, 'bullish': { label: '★ Compatible', color: '#34d399' }, 'neutral': { label: '→ Neutral', color: '#64748b' }, 'bearish': { label: '✗ Unfavorable', color: '#f97316' }, 'bearish+': { label: '✗✗ Contra', color: '#ef4444' }, 'defensive':{ label: '⚠ Defensive', color: '#f59e0b' }, } // ── IBKR 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 }] } // ── Sub-components ──────────────────────────────────────────────────────────── function BucketBar({ score, max }: { score: number; max: number }) { const pct = max > 0 ? (score / max) * 100 : 0 const color = pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500/70' return (
) } function ScorePillars({ buckets }: { buckets: any[] }) { if (!buckets?.length) return null return (
{buckets.map((b: any) => { const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0 const color = pct >= 70 ? 'bg-emerald-500' : pct >= 45 ? 'bg-yellow-500' : 'bg-red-500/70' return (
{BUCKET_ICONS[b.id]} {b.score}/{b.max}
) })}
) } function ScoreJustification({ buckets, keyCatalyst }: { buckets: any[]; keyCatalyst?: string }) { return (
{keyCatalyst && (
🔑

{keyCatalyst}

)} {buckets.map((b: any) => { const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0 const sc = pct >= 70 ? 'text-emerald-400' : pct >= 45 ? 'text-yellow-400' : 'text-red-400' return (
{BUCKET_ICONS[b.id] ?? '•'} {b.label} {b.score}/{b.max}
{b.comment && (

{b.comment}

)} {b.subs?.length > 0 && (
{b.subs.map((sub: any) => { const sp = sub.max > 0 ? Math.round((sub.score / sub.max) * 100) : 0 const ssc = sp >= 70 ? 'text-emerald-400' : sp >= 45 ? 'text-yellow-400' : 'text-slate-500' return (
{BUCKET_ICONS[sub.id] ?? '›'} {sub.label} {sub.score}/{sub.max}
{sub.comment && (

{sub.comment}

)}
) })}
)}
) })}
) } 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 (
IBKR Ticket {!entryPrice && Price unavailable — check in IBKR}
Underlying
{underlying}
Security Type: OPT
Strategy
{strategy}
{entryPrice &&
Underlying: ${entryPrice.toFixed(2)}
}
Expiry
{expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}d` : '—'}
{expiryDays ? `${expiryDays}d DTE` : ''}
{legs.map((leg, i) => (
{leg.action} 1 contract {leg.type} Strike {fmtStrike(leg.strike)} {leg.strike === null && strikeGuidance && ({strikeGuidance})}
))}
Order type
LIMIT
Price = premium in IBKR
Max budget
{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1,000€'}
Estimated max loss
Target
{targetGain ? `+${targetGain.toFixed(0)}€` : '—'}
{timing && (
{timing}
)} {invalidation && (
{invalidation}
)}
) } export 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; entry_price?: number; strike_guidance?: string } | null profiles?: any[] }) { const [expanded, setExpanded] = useState(false) const { trade, patternName, assetClass, category, signalDirection, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct, convictionScore, convictionBonus, convergenceCount, convergencePartners } = item const effectiveScore = score !== null ? Math.max(0, Math.min(100, score + (scoreDelta ?? 0))) : null const effectiveConviction = convictionScore !== null ? Math.max(0, Math.min(100, convictionScore + (scoreDelta ?? 0))) : effectiveScore const gainPct = expectedMovePct ?? 0 const evNet = effectiveConviction !== null && gainPct > 0 ? (effectiveConviction / 100) * (gainPct / 100) - (1 - effectiveConviction / 100) : null const matchedProfile = useMemo(() => { if (effectiveConviction === null || !profiles?.length) return undefined return profiles.find(prof => prof.enabled && effectiveConviction >= prof.min_score && gainPct >= prof.min_gain_pct ) ?? null }, [effectiveConviction, gainPct, profiles]) const breakdown = scoreInfo?.score_breakdown ?? {} const rationale = scoreInfo?.summary ?? trade.rationale ?? scoreInfo?.key_catalyst ?? '' const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur const target = maxLoss != null && gainPct > 0 ? Math.round(Math.abs(maxLoss) * gainPct / 100) : (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur) const timing = trade.timing_note ?? scoreInfo?.recommended_trade?.timing_note const dirInfo = SIGNAL_DIR[signalDirection] ?? null return (
= 70, 'border-yellow-700/30': effectiveConviction !== null && effectiveConviction >= 50 && effectiveConviction < 70, 'border-slate-700/20': effectiveConviction === null, })}>
{patternName} {category && {category}}
{assetClass} {dirInfo && {dirInfo.label}} {trade.underlying && {trade.underlying}} {trade.isRecommended && effectiveScore !== null && ( ★ IA )}
{trade.strategy && {trade.strategy}}
{effectiveScore !== null ? (
{effectiveConviction ?? effectiveScore}
/100 {scoreDelta !== null && scoreDelta !== 0 && ( 0 ? 'text-emerald-400' : 'text-red-400')}> {scoreDelta > 0 ? '+' : ''}{scoreDelta} )}
{convictionBonus > 0 && (
⟳+{convictionBonus}
)} {scoreInfo?.score_trend != null && (
0 ? 'text-emerald-400' : scoreInfo.score_trend < 0 ? 'text-red-400' : 'text-slate-600')}> {scoreInfo.score_trend > 0 ? '↑+' : scoreInfo.score_trend < 0 ? '↓' : '→'}{scoreInfo.score_trend !== 0 ? Math.abs(scoreInfo.score_trend) : ''}
)}
) : ( to score )}
{convergenceCount > 0 && (
⟳ ×{convergenceCount + 1} patterns convergents · {convergencePartners.slice(0, 2).map(p => p.name.split(' ')[0]).join(', ')}
)} {effectiveScore !== null && ( <>
{scoreInfo?.buckets?.length > 0 && } )} {effectiveScore !== null && (
Gain:{' '} 0 ? 'text-slate-300' : 'text-orange-400/80'}>{gainPct > 0 ? `${gainPct}%` : '?'} {evNet !== null ? ( <>· = 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}% ) : gainPct === 0 ? ( <>·EV ? ) : null} {matchedProfile !== undefined && ( <>· {matchedProfile ? ● {matchedProfile.name} : ✗ {gainPct === 0 ? 'Gain undefined' : 'No profile'} } )}
)} {rankRationale &&
{rankRationale}
} {macroInfo && macroInfo.dominant !== 'incertain' && (() => { const bias = macroInfo.assetBias[assetClass] ?? 'neutral' const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral'] return (
{macroInfo.emoji} {macroInfo.label} · {bd.label}
) })()} {rationale &&

{rationale}

} {timing &&
⏱ {timing}
} {(maxLoss != null || target != null) && (
{maxLoss != null && Max -{Math.abs(maxLoss)}€} {target != null && Cible +{target}€} {scoreInfo?.confidence && conf. {scoreInfo.confidence}%}
)} {effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && ( <> {expanded && (
{scoreInfo?.buckets?.length > 0 ? ( ) : (
{Object.entries(breakdown).map(([k, v]: [string, any]) => (
{k.replace(/_/g, ' ')}
{v}/25
))}
)}
)} )} {addedInfo && (
Added on {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
)}
) } export 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; entry_price?: number; strike_guidance?: string } | null profiles?: any[]; rank: number }) { const [expanded, setExpanded] = useState(false) const { trade, patternName, assetClass, category, signalDirection, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct, convictionScore, convictionBonus, convergenceCount, convergencePartners } = item const effectiveScore = score !== null ? Math.max(0, Math.min(100, score + (scoreDelta ?? 0))) : null const effectiveConviction = convictionScore !== null ? Math.max(0, Math.min(100, convictionScore + (scoreDelta ?? 0))) : effectiveScore const gainPct = expectedMovePct ?? 0 const evNet = effectiveConviction !== null && gainPct > 0 ? (effectiveConviction / 100) * (gainPct / 100) - (1 - effectiveConviction / 100) : null const matchedProfile = useMemo(() => { if (effectiveConviction === null || !profiles?.length) return undefined return profiles.find(p => p.enabled && effectiveConviction >= p.min_score && gainPct >= p.min_gain_pct) ?? null }, [effectiveConviction, gainPct, profiles]) const dirInfo = SIGNAL_DIR[signalDirection] ?? null const bias = macroInfo?.assetBias[assetClass] ?? 'neutral' const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral'] const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur const target = maxLoss != null && gainPct > 0 ? Math.round(Math.abs(maxLoss) * gainPct / 100) : (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur) const daysHeld = addedInfo?.entry_date ? Math.floor((Date.now() - new Date(addedInfo.entry_date).getTime()) / 86400000) : null const horizon = addedInfo?.expiry_days ?? null const maturityPct = daysHeld !== null && horizon ? Math.min(100, Math.round(daysHeld / horizon * 100)) : null const maturityColor = maturityPct === null ? 'text-slate-700' : maturityPct >= 75 ? 'text-orange-400' : maturityPct >= 35 ? 'text-emerald-400' : maturityPct >= 10 ? 'text-yellow-400' : 'text-slate-500' return ( = 70 && 'border-l-2 border-l-emerald-600/50', effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70 && 'border-l-2 border-l-yellow-600/30', )} onClick={() => setExpanded(v => !v)} > {rank}
{patternName} {category && {category.split('_')[0]}}
{assetClass} {dirInfo && {dirInfo.label}} {trade.underlying && {trade.underlying}} {trade.isRecommended && ★ IA} {convergenceCount > 0 && ⟳×{convergenceCount + 1}}
{trade.strategy ? {trade.strategy} : } {effectiveConviction !== null ? (
{effectiveConviction}
{scoreDelta !== null && scoreDelta !== 0 && ( 0 ? 'text-emerald-400' : 'text-red-400')}> {scoreDelta > 0 ? '+' : ''}{scoreDelta} )} {convictionBonus > 0 && ( ⟳+{convictionBonus} )}
) : to score} {effectiveConviction !== null && (
)} {evNet !== null ? = 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}% : } {gainPct > 0 ? `${gainPct}%` : '—'} {maxLoss != null &&
-{Math.abs(maxLoss)}€
} {target != null &&
+{target}€
} {matchedProfile !== undefined && ( matchedProfile ? ● {matchedProfile.name} : ✗ Aucun )} {bd.label} {addedInfo?.entry_date ? {format(new Date(addedInfo.entry_date), "d MMM", { locale: fr })} : } {daysHeld !== null ? (
{daysHeld}j{horizon ? `/${horizon}j` : ''} {maturityPct !== null && (
= 75 ? 'bg-orange-500' : maturityPct >= 35 ? 'bg-emerald-500' : 'bg-yellow-500')} style={{ width: `${maturityPct}%` }} />
)}
) : } {expanded ? : } {expanded && (
{scoreInfo?.key_catalyst && (
🔑

{scoreInfo.key_catalyst}

)} {rankRationale &&

{rankRationale}

} {scoreInfo?.summary &&

{scoreInfo.summary}

} {scoreInfo?.buckets?.length > 0 && ( )}
{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 && (
Added on {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
)}
{trade.strategy && trade.underlying && ( )}
)} ) } // ── AI-proposed trades — awaiting explicit user confirmation ────────────────── // Created by the chat widget's propose_trade tool call. Deliberately kept // separate from TradeCard/TradeItem (those are derived from scored patterns — // grafting an AI-sourced item onto that shape would be fragile). function AiProposedTradesSection() { const { data: proposals } = useAiTradeProposals('pending') const { mutate: confirmProposal, isPending: confirming } = useConfirmAiTradeProposal() const { mutate: rejectProposal, isPending: rejecting } = useRejectAiTradeProposal() const [pendingId, setPendingId] = useState(null) const [error, setError] = useState(null) if (!proposals || proposals.length === 0) return null const handleConfirm = (id: string) => { setPendingId(id); setError(null) confirmProposal(id, { onError: (e: any) => setError(e?.response?.data?.detail ?? 'Erreur lors de la confirmation.'), onSettled: () => setPendingId(null), }) } const handleReject = (id: string) => { setPendingId(id) rejectProposal(id, { onSettled: () => setPendingId(null) }) } return (

Idées proposées par l'IA ({proposals.length} en attente)

{error &&
{error}
}
{proposals.map(p => (
{p.title}
{p.underlying} · {p.strategy} · {p.capital_invested.toLocaleString('fr-FR')} €
🤖 IA
{p.rationale &&
{p.rationale}
}
))}
) } // ── Self-contained Trade Ideas Tab ──────────────────────────────────────────── export function TradeIdeasTab() { const { data: allPatternsData } = useAllPatterns() const { data: lastScoresData } = useLastScores() const { data: aiStatus } = useAiStatus() const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open') const { data: tradeMtmData } = useTradeMtm(30) const { data: riskProfilesData } = useRiskProfiles() const { data: macroData } = useMacroRegime() const { mutate: scorePatterns, isPending: scoring } = useScorePatterns() const { mutate: addPos } = useAddPosition() const riskProfiles: any[] = (riskProfilesData as any)?.profiles ?? [] const allPatterns: any[] = allPatternsData ?? [] const scoredAt: string | null = lastScoresData?.scored_at ?? null const { data: configData } = useConfig() const cs = (configData as any) ?? {} const tradeBudget: number = cs.trade_budget_eur ?? 5000 const horizonMin: number = cs.preferred_horizon_min ?? 30 const horizonMax: number = cs.preferred_horizon_max ?? 180 const [categoryFilter, setCategoryFilter] = useState('all') const [thematicFilter, setThematicFilter] = useState('all') const [horizonFilter, setHorizonFilter] = useState('all') const [topN, setTopN] = useState(10) const [viewMode, setViewMode] = useState<'cards' | 'grid'>('grid') const [toast, setToast] = useState<{ title: string; sub: string } | null>(null) useEffect(() => { if (!toast) return const t = setTimeout(() => setToast(null), 3500) return () => clearTimeout(t) }, [toast]) const scoreMap = useMemo(() => { const map: Record = {} for (const sp of (lastScoresData?.scored_patterns ?? [])) { if (sp.pattern_id) map[sp.pattern_id] = sp } return map }, [lastScoresData]) const macroInfo = useMemo(() => { if (!macroData?.scenarios) return null const sc = macroData.scenarios const dom = sc.dominant ?? 'incertain' const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' } return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias: sc.asset_bias?.[dom] ?? {} } }, [macroData]) const addedMap = useMemo(() => { const map: Record = {} const upsert = (key: string, entry_date: string, expiry_days?: number) => { if (!map[key] || entry_date > map[key].entry_date) map[key] = { entry_date, expiry_days } } for (const pos of (positions as any[] ?? [])) { const strategy = (pos.strategy ?? '').toLowerCase() const trigger = (pos.geo_trigger ?? '').toLowerCase() const underly = (pos.underlying ?? '').toLowerCase() const expiry_days = pos.expiry_days ?? undefined if (trigger) upsert(`trigger:${trigger}:${strategy}`, pos.entry_date ?? '', expiry_days) if (underly) upsert(`ticker:${underly}:${strategy}`, pos.entry_date ?? '', expiry_days) } return map }, [positions]) const mtmMap = useMemo(() => { 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) ?? '' if (!map[pid] || entry_date > map[pid].entry_date) map[pid] = { entry_date, expiry_days: t.expiry_days_at_entry ?? t.horizon_days, entry_price: t.entry_price, strike_guidance: t.strike_guidance } } return map }, [tradeMtmData]) const HORIZON_BUCKETS = [ { key: 'all', label: 'All horizons' }, { key: 'lt30', label: '< 1M', min: 0, max: 29 }, { key: '1-3m', label: '1–3M', min: 30, max: 90 }, { key: '3-6m', label: '3–6M', min: 91, max: 180 }, { key: 'gt6m', label: '> 6M', min: 181, max: 9999 }, ] const { topScored, allUnscored } = useMemo(() => { const bucket = HORIZON_BUCKETS.find(b => b.key === horizonFilter) const filtered = allPatterns.filter(p => { if (categoryFilter !== 'all' && p.asset_class !== categoryFilter) return false if (thematicFilter !== 'all' && p.category !== thematicFilter) return false if (bucket && bucket.key !== 'all') { const hd = p.horizon_days ?? 0 if (hd < bucket.min! || hd > bucket.max!) return false } return true }) const scored: TradeItem[] = [] const unscored: TradeItem[] = [] for (const p of filtered) { const sp = scoreMap[p.id] const patCategory = p.category ?? '' const patDirection = p.signal_direction ?? '' if (sp) { const recUnderlying = sp.recommended_trade?.underlying const trades: any[] = p.suggested_trades ?? [] const tradesOrFallback = trades.length > 0 ? trades : [sp.recommended_trade ?? {}] const rankings: any[] = sp.trade_rankings ?? [] for (const t of tradesOrFallback) { const ranking = rankings.find((r: any) => r.underlying === t.underlying && (!r.strategy || !t.strategy || r.strategy === t.strategy) ) scored.push({ trade: { ...t, isRecommended: recUnderlying && t.underlying === recUnderlying }, patternName: p.name, patternId: p.id, assetClass: t.asset_class ?? p.asset_class, category: sp.category ?? patCategory, signalDirection: sp.signal_direction ?? patDirection, score: sp.score, scoreInfo: sp, scoreDelta: ranking?.score_delta ?? null, rankRationale: ranking?.rationale ?? null, expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0, convictionScore: sp.conviction_score ?? null, convictionBonus: sp.conviction_bonus ?? 0, convergenceCount: sp.convergence_count ?? 0, convergencePartners: sp.convergence_partners ?? [], }) } } else { const trades: any[] = p.suggested_trades ?? [] const base = { category: patCategory, signalDirection: patDirection, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, convictionScore: null, convictionBonus: 0, convergenceCount: 0, convergencePartners: [] } if (trades.length === 0) { unscored.push({ trade: {}, patternName: p.name, patternId: p.id, assetClass: p.asset_class, expectedMovePct: p.expected_move_pct ?? 0, ...base }) } else { for (const t of trades) { unscored.push({ trade: t, patternName: p.name, patternId: p.id, assetClass: t.asset_class ?? p.asset_class, expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0, ...base }) } } } } // Sort by conviction_score (includes convergence bonus) then effective trade score const effScore = (item: TradeItem) => Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0))) const convScore = (item: TradeItem) => item.convictionScore !== null ? Math.max(0, Math.min(100, item.convictionScore + (item.scoreDelta ?? 0))) : effScore(item) scored.sort((a, b) => convScore(b) - convScore(a)) const sliced = topN === 0 ? scored : scored.slice(0, topN) return { topScored: sliced, allUnscored: unscored } }, [allPatterns, scoreMap, categoryFilter, thematicFilter, horizonFilter, topN]) const handleAdd = (item: TradeItem) => { const t = item.trade const sp = item.scoreInfo addPos({ title: `${t.strategy ?? ''} ${t.underlying ?? ''} — ${item.patternName}`.trim(), underlying: t.underlying ?? item.patternName, strategy: t.strategy ?? '', asset_class: item.assetClass, expiry_days: t.expiry_days ?? sp?.recommended_trade?.expiry_days ?? 90, capital_invested: Math.abs(t.max_loss_eur ?? sp?.recommended_trade?.max_loss_eur ?? 1000), geo_trigger: item.patternName, rationale: t.rationale ?? sp?.key_catalyst ?? '', legs: [{ option_type: (t.strategy ?? '').toLowerCase().includes('put') ? 'put' : 'call', quantity: 1, position: 'long' }], }, { onSuccess: () => { refetchPositions() setToast({ title: 'Added to portfolio', sub: `${t.strategy ?? ''} ${t.underlying ?? ''} · ${item.patternName}`.trim() }) }, }) } const getAddedInfo = (item: TradeItem) => { const strategy = (item.trade.strategy ?? '').toLowerCase() const trigger = (item.patternName ?? '').toLowerCase() const underly = (item.trade.underlying ?? '').toLowerCase() return addedMap[`trigger:${trigger}:${strategy}`] ?? addedMap[`ticker:${underly}:${strategy}`] ?? mtmMap[item.patternId] ?? null } return (
{/* Toolbar */}

Trade Ideas

{scoredAt && ( — scored on {format(new Date(scoredAt), "d MMM HH:mm", { locale: fr })} )} {topScored.length > 0 && ( {topScored.length} scored · {allUnscored.length} to score )}
{/* Asset class filter */}
{CATEGORIES.map(c => ( ))}
{/* Thematic category filter */}
{THEMATIC_CATEGORIES.map(c => ( ))}
{/* Horizon filter */}
{HORIZON_BUCKETS.map(b => ( ))}
{/* Budget indicator */}
Budget €{tradeBudget.toLocaleString()} · {horizonMin}–{horizonMax}d
{/* Top N */}
{[5, 10, 20, 0].map(n => ( ))}
{/* View toggle */}
{/* Score button */} {aiStatus?.enabled ? ( ) : ( OpenAI key required )}
{/* Scored trades */} {topScored.length > 0 && ( viewMode === 'cards' ? (
{topScored.map((item, i) => ( ))}
) : (
{topScored.map((item, i) => ( ))}
# Pattern / Ticker Strategy Score EV Move Max/Target Profile Macro Entry Duration
) )} {/* Unscored trades */} {allUnscored.length > 0 && ( <> {topScored.length > 0 && (
{allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} to score
)} {viewMode === 'cards' ? (
{allUnscored.map((item, i) => ( ))}
) : (
{allUnscored.map((item, i) => ( ))}
)} )} {allPatterns.length === 0 && (
Start the backend — patterns loading…
)} {/* Toast */} {toast && (
{toast.title}
{toast.sub}
)}
) }