From f2b020ee4b02f6f4866ec470c790b92601d85f94 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 19 Jun 2026 19:36:11 +0200 Subject: [PATCH] refactor: move trade ideas to Journal tab, tabify Config, trim Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/TradeIdeas.tsx | 921 ++++++++++++++++++ frontend/src/pages/Config.tsx | 809 +++++++++------- frontend/src/pages/Dashboard.tsx | 1211 +----------------------- frontend/src/pages/JournalDeBord.tsx | 7 +- 4 files changed, 1420 insertions(+), 1528 deletions(-) create mode 100644 frontend/src/components/TradeIdeas.tsx diff --git a/frontend/src/components/TradeIdeas.tsx b/frontend/src/components/TradeIdeas.tsx new file mode 100644 index 0000000..d8d7685 --- /dev/null +++ b/frontend/src/components/TradeIdeas.tsx @@ -0,0 +1,921 @@ +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 = { + 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 = { + '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 ( +
+
+
+ ) +} + +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 ( +
+
+ Ticket IBKR + {!entryPrice && Prix non disponible — vérifier dans IBKR} +
+
+
+
Sous-jacent
+
{underlying}
+
Security Type: OPT
+
+
+
Stratégie
+
{strategy}
+ {entryPrice &&
Sous-jacent: ${entryPrice.toFixed(2)}
} +
+
+
Expiration
+
+ {expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'} +
+
{expiryDays ? `${expiryDays}j DTE` : ''}
+
+
+
+ {legs.map((leg, i) => ( +
+ {leg.action} + 1 contrat + {leg.type} + Strike + {fmtStrike(leg.strike)} + {leg.strike === null && strikeGuidance && ({strikeGuidance})} +
+ ))} +
+
+
+
Type d'ordre
+
LIMIT
+
Prix = prime dans IBKR
+
+
+
Budget max
+
{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000€'}
+
Perte max estimée
+
+
+
Cible
+
{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, 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 ( +
= 70, + 'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70, + 'border-slate-700/20': effectiveScore === null, + })}> +
{patternName}
+
+
+
+ {assetClass} + {trade.underlying && {trade.underlying}} + {trade.isRecommended && effectiveScore !== null && ( + ★ IA + )} +
+ {trade.strategy && {trade.strategy}} +
+ {effectiveScore !== null ? ( +
+
{effectiveScore}
+
+ /100 + {scoreDelta !== null && scoreDelta !== 0 && ( + 0 ? 'text-emerald-400' : 'text-red-400')}> + {scoreDelta > 0 ? '+' : ''}{scoreDelta} + + )} +
+ {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) : ''} +
+ )} +
+ ) : ( + à scorer + )} +
+ {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 non défini' : 'Aucun profil'} + } + )} +
+ )} + {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 && ( +
+ + Ajouté le {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, 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 ( + + = 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}
+
+ {assetClass} + {trade.underlying && {trade.underlying}} + {trade.isRecommended && ★ IA} +
+ + + {trade.strategy + ? {trade.strategy} + : } + + + {effectiveScore !== null ? ( +
+ {effectiveScore} + {scoreDelta !== null && scoreDelta !== 0 && ( + 0 ? 'text-emerald-400' : 'text-red-400')}> + {scoreDelta > 0 ? '+' : ''}{scoreDelta} + + )} +
+ ) : à scorer} + + + {effectiveScore !== 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 && ( +
+ + Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })} +
+ )} + +
+
+ {trade.strategy && trade.underlying && ( + + )} +
+ + + )} + + ) +} + +// ── 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 = {} + 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 { 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 ( +
+ {/* Toolbar */} +
+
+

+ Idées de trade +

+ {scoredAt && ( + — scoré le {format(new Date(scoredAt), "d MMM à HH:mm", { locale: fr })} + )} + {topScored.length > 0 && ( + {topScored.length} scoré{topScored.length > 1 ? 's' : ''} · {allUnscored.length} à scorer + )} +
+
+ {/* Category filter */} +
+ {CATEGORIES.map(c => ( + + ))} +
+ {/* Top N */} +
+ {[5, 10, 20, 0].map(n => ( + + ))} +
+ {/* View toggle */} +
+ + +
+ {/* Score button */} + {aiStatus?.enabled ? ( + + ) : ( + Clé OpenAI requise + )} +
+
+ + {/* Scored trades */} + {topScored.length > 0 && ( + viewMode === 'cards' ? ( +
+ {topScored.map((item, i) => ( + + ))} +
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + {topScored.map((item, i) => ( + + ))} + +
#Pattern / TickerStratégieScoreEVGainMax/CibleProfilMacroEntréeDurée
+
+ ) + )} + + {/* Unscored trades */} + {allUnscored.length > 0 && ( + <> + {topScored.length > 0 && ( +
+ + {allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} à scorer + +
+ )} + {viewMode === 'cards' ? ( +
+ {allUnscored.map((item, i) => ( + + ))} +
+ ) : ( +
+ + + {allUnscored.map((item, i) => ( + + ))} + +
+
+ )} + + )} + + {allPatterns.length === 0 && ( +
+ Démarrer le backend — patterns en cours de chargement… +
+ )} + + {/* Toast */} + {toast && ( +
+ +
+
{toast.title}
+
{toast.sub}
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 80a6333..0d72132 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -348,6 +348,7 @@ export default function Config() { const [fredKey, setFredKey] = useState('') const [showKeys, setShowKeys] = useState(false) const [savedMsg, setSavedMsg] = useState('') + const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'journal' | 'profiles'>('ia') // Analysis config local state const [analysisTopN, setAnalysisTopN] = useState(10) @@ -428,24 +429,48 @@ export default function Config() { }) } + const CONFIG_TABS = [ + { key: 'ia', label: 'IA & Analyse', icon: Brain }, + { key: 'cycle', label: 'Auto-Cycle & Logging', icon: Zap }, + { key: 'sources', label: 'Sources', icon: Globe }, + { key: 'journal', label: 'Journal & Sortie', icon: Lock }, + { key: 'profiles', label: 'Profils de risque', icon: SlidersHorizontal }, + ] as const + return ( -
-
-

- Configuration -

-

Clés API, sources d'information, paramètres IA

+
+
+
+

+ Configuration +

+

Clés API, sources, paramètres IA & cycle

+
+ {savedMsg && ( +
+ {savedMsg} +
+ )}
- {savedMsg && ( -
- {savedMsg} -
- )} + {/* Tab navigation */} +
+ {CONFIG_TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
-
- {/* Left col: API Keys + AI status */} -
+ {/* ── IA & Analyse ── */} + {configTab === 'ia' && ( +
+
{/* AI Status */}
Statut IA
@@ -468,30 +493,328 @@ export default function Config() {
- {/* API Keys */} + {/* OpenAI API Key */}
-
Clés API
+
Clé OpenAI
+
+
+ + + {aiStatus?.enabled ? '✓ Actif' : '○ Inactif'} + +
+ setOpenaiKey(e.target.value)} + placeholder="sk-proj-..." + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono" + /> +
+ +
+
+
+ + {/* Right: Analysis params */} +
+

+ Paramètres d'analyse IA +

+
+
+ +
+ {[5, 10, 15, 20].map(n => ( + + ))} +
+
+
+ + +
+
+
+
+ + +
+