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:
@@ -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 => (
|
||||
|
||||
@@ -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'] }),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user