Files
OpenFin/frontend/src/components/TradeIdeas.tsx
OpenSquared f2b020ee4b refactor: move trade ideas to Journal tab, tabify Config, trim Dashboard
- Extract TradeCard/TradeRow/IBKRTicket + TradeIdeasTab self-contained
  component to frontend/src/components/TradeIdeas.tsx
- Dashboard: remove trade ideas section, MtM section, all related hooks/
  state/computations; replace Signaux Géo mini-card with Trades du cycle;
  add News géo link on Risque Géopolitique card
- JournalDeBord: add Idées de trade tab (left of Ouverts) using TradeIdeasTab
- Config: reorganize into 5 tabs — IA & Analyse, Auto-Cycle & Logging,
  Sources, Journal & Sortie, Profils de risque; expose min_score_threshold
  as a prominent slider in the Auto-Cycle tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:36:11 +02:00

922 lines
46 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,
} from '../hooks/useApi'
import {
Target, Brain, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2,
LayoutGrid, List, Terminal,
} 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: 'Tous' },
{ key: 'energy', label: '⛽ Énergie' },
{ key: 'metals', label: '🥇 Métaux' },
{ key: 'agriculture', label: '🌾 Agri' },
{ key: 'indices', label: '📊 Indices' },
{ key: 'equities', label: '📈 Actions' },
{ key: 'forex', label: '💱 Forex' },
]
export interface TradeItem {
trade: any
patternName: string
patternId: string
assetClass: string
score: number | null
scoreInfo: any | null
scoreDelta: number | null
rankRationale: string | null
expectedMovePct: number
}
const BIAS_DISPLAY: Record<string, { label: string; color: string }> = {
'bullish+': { label: '★★ Compatible', color: '#10b981' },
'bullish': { label: '★ Compatible', color: '#34d399' },
'neutral': { label: '→ Neutre', color: '#64748b' },
'bearish': { label: '✗ Défavorable', color: '#f97316' },
'bearish+': { label: '✗✗ Contra', color: '#ef4444' },
'defensive':{ label: '⚠ Défensif', 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" /> 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>
{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, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
const effectiveScore = score !== null ? Math.max(0, Math.min(100, score + (scoreDelta ?? 0))) : null
const gainPct = expectedMovePct ?? 0
const evNet = effectiveScore !== null && gainPct > 0
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
: null
const matchedProfile = useMemo(() => {
if (effectiveScore === null || !profiles?.length) return undefined
return profiles.find(prof =>
prof.enabled && effectiveScore >= prof.min_score && gainPct >= prof.min_gain_pct
) ?? null
}, [effectiveScore, 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
return (
<div className={clsx('card transition-all', {
'border-emerald-700/50': effectiveScore !== null && effectiveScore >= 70,
'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70,
'border-slate-700/20': effectiveScore === null,
})}>
<div className="text-xs text-slate-600 line-clamp-1 mb-1 font-mono">{patternName}</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>
{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-[48px]">
<div className={clsx('text-2xl font-bold leading-none', scoreColor(effectiveScore))}>{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>
{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">à scorer</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 non défini' : 'Aucun profil'}</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 ? 'Masquer la justification' : ' Justification du score'}
</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>Ajouté le {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 ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
</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, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
const effectiveScore = score !== null ? Math.max(0, Math.min(100, score + (scoreDelta ?? 0))) : null
const gainPct = expectedMovePct ?? 0
const evNet = effectiveScore !== null && gainPct > 0
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
: null
const matchedProfile = useMemo(() => {
if (effectiveScore === null || !profiles?.length) return undefined
return profiles.find(p => p.enabled && effectiveScore >= p.min_score && gainPct >= p.min_gain_pct) ?? null
}, [effectiveScore, gainPct, profiles])
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-[180px]">
<div className="text-slate-400 truncate text-[10px] leading-tight">{patternName}</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="badge badge-blue text-[10px] py-0">{assetClass}</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>}
</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">
{effectiveScore !== null ? (
<div className="flex flex-col items-end gap-0.5">
<span className={clsx('text-base font-bold leading-none', scoreColor(effectiveScore))}>{effectiveScore}</span>
{scoreDelta !== null && scoreDelta !== 0 && (
<span className={clsx('text-[10px] font-mono', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
</span>
)}
</div>
) : <span className="text-slate-600 text-[10px]">à scorer</span>}
</td>
<td className="px-2 py-2 w-20">
{effectiveScore !== null && (
<div className="bg-dark-600 rounded-full h-1.5 w-full">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</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" />
Ajouté le {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 ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
</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>
)
}
// ── 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 [categoryFilter, setCategoryFilter] = 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 { topScored, allUnscored } = useMemo(() => {
const filtered = allPatterns.filter(p => categoryFilter === 'all' || p.asset_class === categoryFilter)
const scored: TradeItem[] = []
const unscored: TradeItem[] = []
for (const p of filtered) {
const sp = scoreMap[p.id]
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,
score: sp.score, scoreInfo: sp,
scoreDelta: ranking?.score_delta ?? null,
rankRationale: ranking?.rationale ?? null,
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
})
}
} else {
const trades: any[] = p.suggested_trades ?? []
if (trades.length === 0) {
unscored.push({ trade: {}, patternName: p.name, patternId: p.id, assetClass: p.asset_class, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: p.expected_move_pct ?? 0 })
} else {
for (const t of trades) {
unscored.push({ trade: t, patternName: p.name, patternId: p.id, assetClass: t.asset_class ?? p.asset_class, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0 })
}
}
}
}
const effScore = (item: TradeItem) => Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
scored.sort((a, b) => effScore(b) - effScore(a))
const sliced = topN === 0 ? scored : scored.slice(0, topN)
return { topScored: sliced, allUnscored: unscored }
}, [allPatterns, scoreMap, categoryFilter, 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: 'Ajouté au portefeuille', 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">
{/* 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" /> Idées de trade
</h2>
{scoredAt && (
<span className="text-xs text-slate-600">— scoré le {format(new Date(scoredAt), "d MMM à HH:mm", { locale: fr })}</span>
)}
{topScored.length > 0 && (
<span className="text-xs text-emerald-500">{topScored.length} scoré{topScored.length > 1 ? 's' : ''} · {allUnscored.length} à scorer</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Category 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>
{/* 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 ? 'Tous' : `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" /> Scorer les patterns</>}
</button>
) : (
<span className="text-xs text-slate-600 border border-slate-700/30 rounded px-2 py-1">Clé OpenAI requise</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">Stratégie</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">Gain</th>
<th className="px-2 py-2 text-right">Max/Cible</th>
<th className="px-2 py-2">Profil</th>
<th className="px-2 py-2">Macro</th>
<th className="px-2 py-2 text-right">Entrée</th>
<th className="px-2 py-2 text-right">Durée</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' : ''} à scorer
<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">
Démarrer le backend patterns en cours de chargement
</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>
)
}