From f8a0a6d02342b329ba615ecb2067ccfe02625fef Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 21 Jun 2026 20:31:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20pattern=20convergence=20UI=20=E2=80=94?= =?UTF-8?q?=20thematic=20filter,=20signal=20direction,=20conviction=20scor?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradeIdeas.tsx: - THEMATIC_CATEGORIES const (8 catégories: géopolitique, macro_monétaire, technique, commodités_supply, risk_off, flux_saisonnier, géo_économique, crédit_stress) - SIGNAL_DIR map (bullish ▲ vert / bearish ▼ rouge / volatility ⟷ violet / neutral ↔ gris) - TradeItem interface: + category, signalDirection, convictionScore, convictionBonus, convergenceCount, convergencePartners - useMemo: double filter (asset_class + thematic category); sort by conviction_score; populate new fields from pattern + scoreInfo - Toolbar: thematic filter row (violet) séparé du filtre asset_class (bleu) - TradeCard: category badge violet, signal direction arrow, conviction badge ⟳+N, convergence banner quand count > 0 - TradeRow: score column shows conviction_score + ⟳+N bonus; direction arrow; category chip abrégé dans le nom pattern useApi.ts: useUpdateCycleConfig type extended with weekend_cycle_enabled + weekend_cycle_times Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/TradeIdeas.tsx | 150 +++++++++++++++++++------ frontend/src/hooks/useApi.ts | 2 +- 2 files changed, 116 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/TradeIdeas.tsx b/frontend/src/components/TradeIdeas.tsx index d8d7685..b60c5ea 100644 --- a/frontend/src/components/TradeIdeas.tsx +++ b/frontend/src/components/TradeIdeas.tsx @@ -42,16 +42,41 @@ export const CATEGORIES = [ { key: 'forex', label: '💱 Forex' }, ] +export const THEMATIC_CATEGORIES = [ + { key: 'all', label: 'Toutes' }, + { key: 'géopolitique', label: '⚔️ Géopo' }, + { key: 'macro_monétaire', label: '🏦 Macro' }, + { key: 'technique', label: '📐 Technique' }, + { key: 'commodités_supply',label: '🛢️ Supply' }, + { key: 'risk_off', label: '🌩️ Risk-off' }, + { key: 'flux_saisonnier', label: '📅 Flux' }, + { key: 'géo_économique', label: '🌐 Géo-éco' }, + { key: 'crédit_stress', label: '💳 Crédit' }, +] + +const SIGNAL_DIR: Record = { + bullish: { label: '▲', color: '#10b981' }, + bearish: { label: '▼', color: '#ef4444' }, + volatility: { label: '⟷', color: '#a78bfa' }, + neutral: { label: '↔', color: '#94a3b8' }, +} + export interface TradeItem { trade: any patternName: string patternId: string assetClass: string + category: string + signalDirection: string score: number | null scoreInfo: any | null scoreDelta: number | null rankRationale: string | null expectedMovePct: number + convictionScore: number | null + convictionBonus: number + convergenceCount: number + convergencePartners: { name: string; category: string; score: number }[] } const BIAS_DISPLAY: Record = { @@ -283,18 +308,19 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { profiles?: any[] }) { const [expanded, setExpanded] = useState(false) - const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item + 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 = effectiveScore !== null && gainPct > 0 - ? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100) + const evNet = effectiveConviction !== null && gainPct > 0 + ? (effectiveConviction / 100) * (gainPct / 100) - (1 - effectiveConviction / 100) : null const matchedProfile = useMemo(() => { - if (effectiveScore === null || !profiles?.length) return undefined + if (effectiveConviction === null || !profiles?.length) return undefined return profiles.find(prof => - prof.enabled && effectiveScore >= prof.min_score && gainPct >= prof.min_gain_pct + prof.enabled && effectiveConviction >= prof.min_score && gainPct >= prof.min_gain_pct ) ?? null - }, [effectiveScore, gainPct, profiles]) + }, [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 @@ -302,18 +328,23 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { ? Math.round(Math.abs(maxLoss) * gainPct / 100) : (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur) const timing = trade.timing_note ?? scoreInfo?.recommended_trade?.timing_note + const dirInfo = SIGNAL_DIR[signalDirection] ?? null return (
= 70, - 'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70, - 'border-slate-700/20': effectiveScore === null, + 'border-emerald-700/50': effectiveConviction !== null && effectiveConviction >= 70, + 'border-yellow-700/30': effectiveConviction !== null && effectiveConviction >= 50 && effectiveConviction < 70, + 'border-slate-700/20': effectiveConviction === null, })}> -
{patternName}
+
+ {patternName} + {category && {category}} +
{assetClass} + {dirInfo && {dirInfo.label}} {trade.underlying && {trade.underlying}} {trade.isRecommended && effectiveScore !== null && ( ★ IA @@ -322,8 +353,8 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { {trade.strategy && {trade.strategy}}
{effectiveScore !== null ? ( -
-
{effectiveScore}
+
+
{effectiveConviction ?? effectiveScore}
/100 {scoreDelta !== null && scoreDelta !== 0 && ( @@ -332,6 +363,9 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { )}
+ {convictionBonus > 0 && ( +
⟳+{convictionBonus}
+ )} {scoreInfo?.score_trend != null && (
0 ? 'text-emerald-400' : scoreInfo.score_trend < 0 ? 'text-red-400' : 'text-slate-600')}> {scoreInfo.score_trend > 0 ? '↑+' : scoreInfo.score_trend < 0 ? '↓' : '→'}{scoreInfo.score_trend !== 0 ? Math.abs(scoreInfo.score_trend) : ''} @@ -342,6 +376,13 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: { à scorer )}
+ {convergenceCount > 0 && ( +
+ ⟳ ×{convergenceCount + 1} + patterns convergents · + {convergencePartners.slice(0, 2).map(p => p.name.split(' ')[0]).join(', ')} +
+ )} {effectiveScore !== null && ( <>
@@ -445,16 +486,18 @@ export function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: profiles?: any[]; rank: number }) { const [expanded, setExpanded] = useState(false) - const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item + 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 = effectiveScore !== null && gainPct > 0 - ? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100) + const evNet = effectiveConviction !== null && gainPct > 0 + ? (effectiveConviction / 100) * (gainPct / 100) - (1 - effectiveConviction / 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]) + 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 @@ -484,12 +527,17 @@ export function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: onClick={() => setExpanded(v => !v)} > {rank} - -
{patternName}
+ +
+ {patternName} + {category && {category.split('_')[0]}} +
{assetClass} + {dirInfo && {dirInfo.label}} {trade.underlying && {trade.underlying}} {trade.isRecommended && ★ IA} + {convergenceCount > 0 && ⟳×{convergenceCount + 1}}
@@ -498,21 +546,26 @@ export function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: : } - {effectiveScore !== null ? ( + {effectiveConviction !== null ? (
- {effectiveScore} - {scoreDelta !== null && scoreDelta !== 0 && ( - 0 ? 'text-emerald-400' : 'text-red-400')}> - {scoreDelta > 0 ? '+' : ''}{scoreDelta} - - )} + {effectiveConviction} +
+ {scoreDelta !== null && scoreDelta !== 0 && ( + 0 ? 'text-emerald-400' : 'text-red-400')}> + {scoreDelta > 0 ? '+' : ''}{scoreDelta} + + )} + {convictionBonus > 0 && ( + ⟳+{convictionBonus} + )} +
) : à scorer} - {effectiveScore !== null && ( + {effectiveConviction !== null && (
-
+
)} @@ -646,6 +699,7 @@ export function TradeIdeasTab() { const scoredAt: string | null = lastScoresData?.scored_at ?? null const [categoryFilter, setCategoryFilter] = useState('all') + const [thematicFilter, setThematicFilter] = useState('all') const [topN, setTopN] = useState(10) const [viewMode, setViewMode] = useState<'cards' | 'grid'>('grid') const [toast, setToast] = useState<{ title: string; sub: string } | null>(null) @@ -701,11 +755,16 @@ export function TradeIdeasTab() { }, [tradeMtmData]) const { topScored, allUnscored } = useMemo(() => { - const filtered = allPatterns.filter(p => categoryFilter === 'all' || p.asset_class === categoryFilter) + const filtered = allPatterns.filter(p => + (categoryFilter === 'all' || p.asset_class === categoryFilter) && + (thematicFilter === 'all' || p.category === thematicFilter) + ) 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 ?? [] @@ -719,28 +778,37 @@ export function TradeIdeasTab() { 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, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: p.expected_move_pct ?? 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, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0 }) + 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))) - scored.sort((a, b) => effScore(b) - effScore(a)) + 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, topN]) + }, [allPatterns, scoreMap, categoryFilter, thematicFilter, topN]) const handleAdd = (item: TradeItem) => { const t = item.trade @@ -786,7 +854,7 @@ export function TradeIdeasTab() { )}
- {/* Category filter */} + {/* Asset class filter */}
{CATEGORIES.map(c => ( ))}
+ {/* Thematic category filter */} +
+ {THEMATIC_CATEGORIES.map(c => ( + + ))} +
{/* Top N */}
{[5, 10, 20, 0].map(n => ( diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index f2ab4a6..a27d65c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -322,7 +322,7 @@ export const useCycleHistory = (limit = 20) => export const useUpdateCycleConfig = () => { const qc = useQueryClient() return useMutation({ - mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number; journal_retention_days?: number; maturity_threshold_pct?: number }) => + mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number; journal_retention_days?: number; maturity_threshold_pct?: number; weekend_cycle_enabled?: boolean; weekend_cycle_times?: string }) => api.post('/cycle/config', cfg).then(r => r.data), onSuccess: () => qc.invalidateQueries({ queryKey: ['cycle-status'] }), })