feat: pattern convergence UI — thematic filter, signal direction, conviction score

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-21 20:31:55 +02:00
parent 952e326590
commit f8a0a6d023
2 changed files with 116 additions and 36 deletions

View File

@@ -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<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 }> = {
@@ -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 (
<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,
'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="text-xs text-slate-600 line-clamp-1 mb-1 font-mono">{patternName}</div>
<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>
@@ -322,8 +353,8 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
{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="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 && (
@@ -332,6 +363,9 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
</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) : ''}
@@ -342,6 +376,13 @@ export function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
<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>
{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">
@@ -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)}
>
<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>
<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">
@@ -498,21 +546,26 @@ export function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }:
: <span className="text-slate-700 text-[10px]">—</span>}
</td>
<td className="px-2 py-2 text-right">
{effectiveScore !== null ? (
{effectiveConviction !== 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>
)}
<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]">à scorer</span>}
</td>
<td className="px-2 py-2 w-20">
{effectiveScore !== null && (
{effectiveConviction !== 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 className={clsx('h-1.5 rounded-full', scoreBg(effectiveConviction))} style={{ width: `${effectiveConviction}%` }} />
</div>
)}
</td>
@@ -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() {
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Category filter */}
{/* 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)}
@@ -798,6 +866,18 @@ export function TradeIdeasTab() {
</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>
{/* Top N */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{[5, 10, 20, 0].map(n => (