Files
OpenFin/frontend/src/components/TradeIdeas.tsx
2026-07-15 08:47:16 +02:00

1111 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<string, { label: string; color: string }> = {
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<string, { label: string; color: string }> = {
'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 (
<div className="w-12 bg-dark-600 rounded-full h-1 shrink-0">
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
)
}
function ScorePillars({ buckets }: { buckets: any[] }) {
if (!buckets?.length) return null
return (
<div className="grid grid-cols-4 gap-1 mb-2">
{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 (
<div key={b.id} className="flex flex-col items-center gap-0.5">
<div className="w-full bg-slate-700/60 rounded-full h-1">
<div className={clsx('h-1 rounded-full', color)} style={{ width: `${pct}%` }} />
</div>
<span className="text-[9px] text-slate-600 truncate w-full text-center leading-none">
{BUCKET_ICONS[b.id]} {b.score}/{b.max}
</span>
</div>
)
})}
</div>
)
}
function ScoreJustification({ buckets, keyCatalyst }: { buckets: any[]; keyCatalyst?: string }) {
return (
<div className="space-y-2 text-xs">
{keyCatalyst && (
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5">
<span className="text-yellow-400 shrink-0">🔑</span>
<p className="text-yellow-200/80 leading-snug">{keyCatalyst}</p>
</div>
)}
{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 (
<div key={b.id} className="bg-dark-700/60 rounded p-2 space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{BUCKET_ICONS[b.id] ?? '•'}</span>
<span className="text-slate-300 font-medium flex-1">{b.label}</span>
<BucketBar score={b.score} max={b.max} />
<span className={clsx('font-mono text-right shrink-0 font-bold', sc)}>{b.score}/{b.max}</span>
</div>
{b.comment && (
<p className="text-slate-400 leading-snug pl-1 border-l-2 border-slate-600">{b.comment}</p>
)}
{b.subs?.length > 0 && (
<div className="pl-2 space-y-1.5 border-l border-slate-700/50 ml-1">
{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 (
<div key={sub.id} className="space-y-0.5">
<div className="flex items-center gap-1">
<span className="text-[11px]">{BUCKET_ICONS[sub.id] ?? ''}</span>
<span className="text-slate-500 flex-1 truncate">{sub.label}</span>
<span className={clsx('font-mono text-[10px] shrink-0', ssc)}>{sub.score}/{sub.max}</span>
</div>
{sub.comment && (
<p className="text-slate-600 leading-snug text-[10px] pl-3">{sub.comment}</p>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}
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" /> IBKR Ticket
{!entryPrice && <span className="ml-auto text-slate-600 font-normal normal-case">Price unavailable check in IBKR</span>}
</div>
<div className="grid grid-cols-3 gap-3 text-[10px]">
<div>
<div className="text-slate-600 mb-0.5">Underlying</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">Strategy</div>
<div className="text-slate-200 font-semibold">{strategy}</div>
{entryPrice && <div className="text-slate-600 text-[9px]">Underlying: ${entryPrice.toFixed(2)}</div>}
</div>
<div>
<div className="text-slate-600 mb-0.5">Expiry</div>
<div className="text-slate-200 font-mono">
{expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}d` : '—'}
</div>
<div className="text-slate-600 text-[9px]">{expiryDays ? `${expiryDays}d 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 contract</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">Order type</div>
<div className="text-slate-300 font-semibold">LIMIT</div>
<div className="text-slate-600 text-[9px]">Price = premium in IBKR</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Max budget</div>
<div className="text-slate-300 font-mono">{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}` : '~1,000€'}</div>
<div className="text-slate-600 text-[9px]">Estimated max loss</div>
</div>
<div>
<div className="text-slate-600 mb-0.5">Target</div>
<div className="text-emerald-400 font-mono">{targetGain ? `+${targetGain.toFixed(0)}` : '—'}</div>
</div>
</div>
{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>
)
}
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<string, string> } | 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 (
<div className={clsx('card transition-all', {
'border-emerald-700/50': effectiveConviction !== null && effectiveConviction >= 70,
'border-yellow-700/30': effectiveConviction !== null && effectiveConviction >= 50 && effectiveConviction < 70,
'border-slate-700/20': effectiveConviction === null,
})}>
<div className="flex items-center gap-1 mb-1">
<span className="text-xs text-slate-600 line-clamp-1 font-mono flex-1">{patternName}</span>
{category && <span className="text-[10px] text-violet-400 bg-violet-900/20 border border-violet-700/30 rounded px-1 shrink-0">{category}</span>}
</div>
<div className="flex items-start justify-between mb-1.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 flex-wrap">
<span className="badge badge-blue text-xs">{assetClass}</span>
{dirInfo && <span className="text-xs font-bold" style={{ color: dirInfo.color }}>{dirInfo.label}</span>}
{trade.underlying && <span className="text-sm text-white font-semibold font-mono">{trade.underlying}</span>}
{trade.isRecommended && effectiveScore !== null && (
<span className="text-xs text-yellow-400 bg-yellow-400/10 border border-yellow-400/30 rounded px-1"> IA</span>
)}
</div>
{trade.strategy && <span className="badge badge-green text-xs mt-0.5">{trade.strategy}</span>}
</div>
{effectiveScore !== null ? (
<div className="ml-2 shrink-0 text-center min-w-[52px]">
<div className={clsx('text-2xl font-bold leading-none', scoreColor(effectiveConviction ?? effectiveScore))}>{effectiveConviction ?? effectiveScore}</div>
<div className="text-xs text-slate-600 flex items-center justify-center gap-0.5">
<span>/100</span>
{scoreDelta !== null && scoreDelta !== 0 && (
<span className={clsx('text-[10px] font-mono font-bold', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
</span>
)}
</div>
{convictionBonus > 0 && (
<div className="text-[10px] text-amber-400 font-mono font-bold">+{convictionBonus}</div>
)}
{scoreInfo?.score_trend != null && (
<div className={clsx('text-[10px] font-mono font-bold mt-0.5', scoreInfo.score_trend > 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) : ''}
</div>
)}
</div>
) : (
<span className="ml-2 shrink-0 text-xs text-slate-500 bg-dark-600 border border-slate-700/40 rounded px-1.5 py-0.5 whitespace-nowrap">to score</span>
)}
</div>
{convergenceCount > 0 && (
<div className="flex items-center gap-1 mb-1.5 text-[10px] text-amber-400 bg-amber-900/10 border border-amber-700/20 rounded px-1.5 py-0.5">
<span className="font-bold"> ×{convergenceCount + 1}</span>
<span className="text-amber-500/70">patterns convergents ·</span>
<span className="truncate text-amber-400/70">{convergencePartners.slice(0, 2).map(p => p.name.split(' ')[0]).join(', ')}</span>
</div>
)}
{effectiveScore !== null && (
<>
<div className="bg-dark-600 rounded-full h-1.5 mb-1.5">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</div>
{scoreInfo?.buckets?.length > 0 && <ScorePillars buckets={scoreInfo.buckets} />}
</>
)}
{effectiveScore !== null && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] flex-wrap">
<span className="text-slate-600">Gain:{' '}<span className={gainPct > 0 ? 'text-slate-300' : 'text-orange-400/80'}>{gainPct > 0 ? `${gainPct}%` : '?'}</span></span>
{evNet !== null ? (
<><span className="text-slate-700">·</span>
<span className={clsx('font-mono font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%</span></>
) : gainPct === 0 ? (
<><span className="text-slate-700">·</span><span className="text-orange-400/70">EV ?</span></>
) : null}
{matchedProfile !== undefined && (
<><span className="text-slate-700">·</span>
{matchedProfile
? <span className="font-semibold" style={{ color: matchedProfile.color }}> {matchedProfile.name}</span>
: <span className="text-red-400/80"> {gainPct === 0 ? 'Gain undefined' : 'No profile'}</span>
}</>
)}
</div>
)}
{rankRationale && <div className="text-[10px] text-slate-600 italic mb-1.5 line-clamp-1">{rankRationale}</div>}
{macroInfo && macroInfo.dominant !== 'incertain' && (() => {
const bias = macroInfo.assetBias[assetClass] ?? 'neutral'
const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral']
return (
<div className="flex items-center gap-1.5 mb-2 text-[10px]">
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
<span className="text-slate-700">·</span>
<span style={{ color: bd.color }}>{bd.label}</span>
</div>
)
})()}
{rationale && <p className="text-xs text-slate-400 line-clamp-2 mb-2">{rationale}</p>}
{timing && <div className="text-xs text-yellow-400/80 mb-2"> {timing}</div>}
{(maxLoss != null || target != null) && (
<div className="flex gap-2 text-xs mb-2">
{maxLoss != null && <span className="text-red-400">Max -{Math.abs(maxLoss)}</span>}
{target != null && <span className="text-emerald-400">Cible +{target}</span>}
{scoreInfo?.confidence && <span className="text-slate-600 ml-auto">conf. {scoreInfo.confidence}%</span>}
</div>
)}
{effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && (
<>
<button onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 mb-1.5 transition-colors">
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{expanded ? 'Hide breakdown' : '↳ Score breakdown'}
</button>
{expanded && (
<div className="mb-2">
{scoreInfo?.buckets?.length > 0 ? (
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={scoreInfo.key_catalyst} />
) : (
<div className="space-y-1 bg-dark-700/50 rounded p-2">
{Object.entries(breakdown).map(([k, v]: [string, any]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-slate-500 capitalize">{k.replace(/_/g, ' ')}</span>
<div className="flex items-center gap-1">
<div className="w-14 bg-dark-600 rounded-full h-1">
<div className="bg-blue-500 h-1 rounded-full" style={{ width: `${(v / 25) * 100}%` }} />
</div>
<span className="text-slate-300 w-5 text-right text-xs">{v}/25</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{addedInfo && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
<CheckCircle2 className="w-3 h-3 shrink-0" />
<span>Added on {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}</span>
</div>
)}
<button onClick={() => onAdd(item)}
className={clsx('w-full flex items-center justify-center gap-1 text-xs rounded py-1 transition-all border',
addedInfo
? '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 ? 'Add again' : 'Add to portfolio'}
</button>
</div>
)
}
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<string, string> } | 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 (
<Fragment>
<tr
className={clsx(
'border-b border-slate-800/60 cursor-pointer transition-colors text-xs',
expanded ? 'bg-dark-700/50' : 'hover:bg-dark-700/30',
effectiveScore !== null && effectiveScore >= 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)}
>
<td className="pl-3 py-2 text-slate-700 font-mono text-[10px] w-6">{rank}</td>
<td className="px-2 py-2 max-w-[200px]">
<div className="flex items-center gap-1">
<span className="text-slate-400 truncate text-[10px] leading-tight flex-1">{patternName}</span>
{category && <span className="text-[9px] text-violet-400 border border-violet-700/30 rounded px-0.5 shrink-0">{category.split('_')[0]}</span>}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="badge badge-blue text-[10px] py-0">{assetClass}</span>
{dirInfo && <span className="text-[11px] font-bold" style={{ color: dirInfo.color }}>{dirInfo.label}</span>}
{trade.underlying && <span className="font-mono text-white text-[11px] font-semibold">{trade.underlying}</span>}
{trade.isRecommended && <span className="text-yellow-400 text-[10px]"> IA</span>}
{convergenceCount > 0 && <span className="text-[10px] text-amber-400 font-bold">×{convergenceCount + 1}</span>}
</div>
</td>
<td className="px-2 py-2">
{trade.strategy
? <span className={clsx('badge text-[10px] py-0', trade.direction === 'bearish' ? 'badge-red' : 'badge-green')}>{trade.strategy}</span>
: <span className="text-slate-700 text-[10px]"></span>}
</td>
<td className="px-2 py-2 text-right">
{effectiveConviction !== null ? (
<div className="flex flex-col items-end gap-0.5">
<span className={clsx('text-base font-bold leading-none', scoreColor(effectiveConviction))}>{effectiveConviction}</span>
<div className="flex items-center gap-0.5">
{scoreDelta !== null && scoreDelta !== 0 && (
<span className={clsx('text-[10px] font-mono', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
</span>
)}
{convictionBonus > 0 && (
<span className="text-[10px] font-mono text-amber-400">+{convictionBonus}</span>
)}
</div>
</div>
) : <span className="text-slate-600 text-[10px]">to score</span>}
</td>
<td className="px-2 py-2 w-20">
{effectiveConviction !== null && (
<div className="bg-dark-600 rounded-full h-1.5 w-full">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveConviction))} style={{ width: `${effectiveConviction}%` }} />
</div>
)}
</td>
<td className="px-2 py-2 text-right font-mono">
{evNet !== null
? <span className={clsx('text-[11px] font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%</span>
: <span className="text-slate-700 text-[10px]"></span>}
</td>
<td className="px-2 py-2 text-right font-mono text-slate-400 text-[11px]">{gainPct > 0 ? `${gainPct}%` : '—'}</td>
<td className="px-2 py-2 text-right text-[10px]">
{maxLoss != null && <div className="text-red-400">-{Math.abs(maxLoss)}</div>}
{target != null && <div className="text-emerald-400">+{target}</div>}
</td>
<td className="px-2 py-2 text-[10px]">
{matchedProfile !== undefined && (
matchedProfile
? <span className="font-semibold" style={{ color: matchedProfile.color }}> {matchedProfile.name}</span>
: <span className="text-red-400/70"> Aucun</span>
)}
</td>
<td className="px-2 py-2 text-[10px]" style={{ color: bd.color }}>{bd.label}</td>
<td className="px-2 py-2 text-right text-[10px] whitespace-nowrap">
{addedInfo?.entry_date
? <span className="text-slate-500">{format(new Date(addedInfo.entry_date), "d MMM", { locale: fr })}</span>
: <span className="text-slate-800"></span>}
</td>
<td className="px-2 py-2 text-right text-[10px]">
{daysHeld !== null ? (
<div className="flex flex-col items-end gap-0.5">
<span className={clsx('font-mono font-semibold', maturityColor)}>{daysHeld}j{horizon ? `/${horizon}j` : ''}</span>
{maturityPct !== null && (
<div className="w-12 h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', maturityPct >= 75 ? 'bg-orange-500' : maturityPct >= 35 ? 'bg-emerald-500' : 'bg-yellow-500')}
style={{ width: `${maturityPct}%` }} />
</div>
)}
</div>
) : <span className="text-slate-800"></span>}
</td>
<td className="pr-3 py-2 text-slate-600 text-right">
{expanded ? <ChevronUp className="w-3.5 h-3.5 inline" /> : <ChevronDown className="w-3.5 h-3.5 inline" />}
</td>
</tr>
{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 space-y-3 text-xs">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
{scoreInfo?.key_catalyst && (
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5 mb-3">
<span className="text-yellow-400 shrink-0">🔑</span>
<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>}
{scoreInfo?.buckets?.length > 0 && (
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={undefined} />
)}
</div>
<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">
<div className="text-orange-400 font-semibold uppercase text-[9px] tracking-wide">Contra signals</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>
)}
{macroInfo && macroInfo.dominant !== 'incertain' && (
<div className="text-[11px]">
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
<span className="text-slate-600 mx-1">·</span>
<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">
<CheckCircle2 className="w-3 h-3 shrink-0" />
Added on {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
</div>
)}
<button
onClick={e => { e.stopPropagation(); onAdd(item) }}
className={clsx('w-full flex items-center justify-center gap-1 text-xs rounded py-1.5 transition-all border',
addedInfo
? '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 ? 'Add again' : 'Add to portfolio'}
</button>
</div>
</div>
{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>
)}
</Fragment>
)
}
// ── 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<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="card border-blue-500/30">
<h3 className="section-title flex items-center gap-1.5 mb-3">
<Bot className="w-3.5 h-3.5 text-blue-400" /> Idées proposées par l'IA
<span className="text-slate-600 font-normal">({proposals.length} en attente)</span>
</h3>
{error && <div className="text-xs text-red-400 mb-2">{error}</div>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{proposals.map(p => (
<div key={p.id} className="rounded-lg border border-slate-700/40 bg-dark-900/60 p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-white">{p.title}</div>
<div className="text-[11px] text-slate-500">{p.underlying} · {p.strategy} · {p.capital_invested.toLocaleString('fr-FR')} €</div>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 shrink-0">🤖 IA</span>
</div>
{p.rationale && <div className="text-[11px] text-slate-400 leading-snug">{p.rationale}</div>}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => handleConfirm(p.id)}
disabled={confirming && pendingId === p.id}
className="flex-1 flex items-center justify-center gap-1 text-xs px-2 py-1.5 rounded bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 disabled:opacity-40 transition-colors"
>
<Check className="w-3 h-3" /> Confirmer
</button>
<button
onClick={() => handleReject(p.id)}
disabled={rejecting && pendingId === p.id}
className="flex-1 flex items-center justify-center gap-1 text-xs px-2 py-1.5 rounded bg-slate-700/40 text-slate-400 hover:bg-slate-700/60 disabled:opacity-40 transition-colors"
>
<XIcon className="w-3 h-3" /> Rejeter
</button>
</div>
</div>
))}
</div>
</div>
)
}
// ── 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<string, any> = {}
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<string, { entry_date: string; expiry_days?: number }> = {}
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<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) ?? ''
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: '13M', min: 30, max: 90 },
{ key: '3-6m', label: '36M', 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 (
<div className="space-y-4">
<AiProposedTradesSection />
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="section-title flex items-center gap-1 mb-0">
<Target className="w-3 h-3" /> Trade Ideas
</h2>
{scoredAt && (
<span className="text-xs text-slate-600">— scored on {format(new Date(scoredAt), "d MMM HH:mm", { locale: fr })}</span>
)}
{topScored.length > 0 && (
<span className="text-xs text-emerald-500">{topScored.length} scored · {allUnscored.length} to score</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Asset class filter */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{CATEGORIES.map(c => (
<button key={c.key} onClick={() => setCategoryFilter(c.key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-blue-600 text-white': categoryFilter === c.key,
'text-slate-400 hover:text-slate-200': categoryFilter !== c.key,
})}>
{c.label}
</button>
))}
</div>
{/* Thematic category filter */}
<div className="flex items-center gap-0.5 bg-dark-700/60 rounded p-0.5 border border-slate-700/40">
{THEMATIC_CATEGORIES.map(c => (
<button key={c.key} onClick={() => setThematicFilter(c.key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-violet-700 text-white': thematicFilter === c.key,
'text-slate-500 hover:text-slate-300': thematicFilter !== c.key,
})}>
{c.label}
</button>
))}
</div>
{/* Horizon filter */}
<div className="flex items-center gap-0.5 bg-dark-700/60 rounded p-0.5 border border-teal-700/30">
{HORIZON_BUCKETS.map(b => (
<button key={b.key} onClick={() => setHorizonFilter(b.key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-teal-700 text-white': horizonFilter === b.key,
'text-slate-500 hover:text-slate-300': horizonFilter !== b.key,
})}>
{b.label}
</button>
))}
</div>
{/* Budget indicator */}
<div className="flex items-center gap-1.5 text-xs text-slate-400 border border-slate-700/40 rounded px-2 py-1">
<span className="text-slate-500">Budget</span>
<span className="font-semibold text-slate-200">€{tradeBudget.toLocaleString()}</span>
<span className="text-slate-600">·</span>
<span className="text-slate-500">{horizonMin}{horizonMax}d</span>
</div>
{/* Top N */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{[5, 10, 20, 0].map(n => (
<button key={n} onClick={() => setTopN(n)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': topN === n,
'text-slate-400 hover:text-slate-200': topN !== n,
})}>
{n === 0 ? 'All' : `Top ${n}`}
</button>
))}
</div>
{/* View toggle */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
<button onClick={() => setViewMode('cards')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'cards' ? 'bg-slate-600 text-white' : 'text-slate-500 hover:text-slate-300')}
title="Vue cartes"><LayoutGrid className="w-3.5 h-3.5" /></button>
<button onClick={() => setViewMode('grid')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-500 hover:text-slate-300')}
title="Vue grille"><List className="w-3.5 h-3.5" /></button>
</div>
{/* Score button */}
{aiStatus?.enabled ? (
<button onClick={() => scorePatterns({})}
disabled={scoring}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-all">
{scoring ? <><RefreshCw className="w-3 h-3 animate-spin" /> Scoring GPT-4o...</> : <><Brain className="w-3 h-3" /> Score patterns</>}
</button>
) : (
<span className="text-xs text-slate-600 border border-slate-700/30 rounded px-2 py-1">OpenAI key required</span>
)}
</div>
</div>
{/* Scored trades */}
{topScored.length > 0 && (
viewMode === 'cards' ? (
<div className="grid grid-cols-5 gap-3 mb-5">
{topScored.map((item, i) => (
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-700/40 mb-5">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700/60 text-slate-600 text-[10px] uppercase tracking-wide">
<th className="pl-3 py-2 text-left w-6">#</th>
<th className="px-2 py-2 text-left">Pattern / Ticker</th>
<th className="px-2 py-2 text-left">Strategy</th>
<th className="px-2 py-2 text-right">Score</th>
<th className="px-2 py-2 w-20"></th>
<th className="px-2 py-2 text-right">EV</th>
<th className="px-2 py-2 text-right">Move</th>
<th className="px-2 py-2 text-right">Max/Target</th>
<th className="px-2 py-2">Profile</th>
<th className="px-2 py-2">Macro</th>
<th className="px-2 py-2 text-right">Entry</th>
<th className="px-2 py-2 text-right">Duration</th>
<th className="pr-3 py-2 w-6"></th>
</tr>
</thead>
<tbody>
{topScored.map((item, i) => (
<TradeRow key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} rank={i + 1} />
))}
</tbody>
</table>
</div>
)
)}
{/* Unscored trades */}
{allUnscored.length > 0 && (
<>
{topScored.length > 0 && (
<div className="flex items-center gap-2 text-xs text-slate-600 mb-3">
<span className="border-b border-slate-700/30 flex-1"></span>
{allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} to score
<span className="border-b border-slate-700/30 flex-1"></span>
</div>
)}
{viewMode === 'cards' ? (
<div className="grid grid-cols-5 gap-3">
{allUnscored.map((item, i) => (
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
<table className="w-full text-xs">
<tbody>
{allUnscored.map((item, i) => (
<TradeRow key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} rank={topScored.length + i + 1} />
))}
</tbody>
</table>
</div>
)}
</>
)}
{allPatterns.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Start the backend patterns loading
</div>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-start gap-3 bg-emerald-950 border border-emerald-600/50 text-emerald-200 rounded-xl px-4 py-3 shadow-2xl min-w-[260px]">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-semibold text-emerald-300">{toast.title}</div>
<div className="text-xs text-emerald-500 mt-0.5 line-clamp-2">{toast.sub}</div>
</div>
</div>
)}
</div>
)
}