feat: wavelets
This commit is contained in:
@@ -6,8 +6,9 @@ import {
|
||||
useTradeMtm, useRiskDashboard, useGeoNews,
|
||||
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
||||
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport,
|
||||
useWaveletWatchlistSignals,
|
||||
} from '../hooks/useApi'
|
||||
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper } from 'lucide-react'
|
||||
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import type { Quote } from '../types'
|
||||
@@ -80,6 +81,26 @@ function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onC
|
||||
)
|
||||
}
|
||||
|
||||
function TradesViewToggle({ value, onChange }: { value: 'top' | 'worst'; onChange: (v: 'top' | 'worst') => void }) {
|
||||
const click = (v: 'top' | 'worst') => (e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation(); onChange(v)
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
|
||||
<button onClick={click('top')}
|
||||
className={clsx('px-1.5 py-0.5 rounded transition-colors',
|
||||
value === 'top' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
|
||||
Best
|
||||
</button>
|
||||
<button onClick={click('worst')}
|
||||
className={clsx('px-1.5 py-0.5 rounded transition-colors',
|
||||
value === 'worst' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
|
||||
Worst
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteRow({ q }: { q: Quote }) {
|
||||
if (!q.price) return null
|
||||
const pos = q.change_pct >= 0
|
||||
@@ -113,6 +134,7 @@ export default function Dashboard() {
|
||||
const { data: watchlistItems } = useInstrumentsWatchlist()
|
||||
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
||||
const { data: latestCycleReportData } = useLatestCycleReport()
|
||||
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
||||
const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker)
|
||||
const { data: ivBatchData } = useIvBatch(watchlistTickers.join(','))
|
||||
|
||||
@@ -144,6 +166,13 @@ export default function Dashboard() {
|
||||
setRiskView(v); localStorage.setItem('dash_risk_view', v)
|
||||
}
|
||||
|
||||
const [tradesView, setTradesView] = useState<'top' | 'worst'>(() =>
|
||||
(localStorage.getItem('dash_trades_view') as any) ?? 'top'
|
||||
)
|
||||
const setTradesViewPersist = (v: 'top' | 'worst') => {
|
||||
setTradesView(v); localStorage.setItem('dash_trades_view', v)
|
||||
}
|
||||
|
||||
// Row height is dictated by the config-driven watchlist cards (Watchlist Radar for row 1,
|
||||
// Options Lab for row 2) — the other cards in each row are capped to match, with internal scroll.
|
||||
const watchlistCardRef = useRef<HTMLDivElement>(null)
|
||||
@@ -925,8 +954,39 @@ export default function Dashboard() {
|
||||
{/* ── Command Center: Intelligence & Contexte ── */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
|
||||
<TradeRankList trades={mtmTrades} mode="top" title="🏆 Top Trades" linkTo="/journal" />
|
||||
<TradeRankList trades={mtmTrades} mode="worst" title="📉 Worst Trades" linkTo="/journal" />
|
||||
<TradeRankList trades={mtmTrades} mode={tradesView} title="🏆 Trades Overview" linkTo="/journal"
|
||||
toggle={<TradesViewToggle value={tradesView} onChange={setTradesViewPersist} />} />
|
||||
|
||||
{/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each cycle) */}
|
||||
{(() => {
|
||||
const signals: any[] = waveletSignalsData?.signals ?? []
|
||||
return (
|
||||
<Link to="/wavelets-simulation" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="section-title mb-0 text-[10px] flex items-center gap-1">
|
||||
<Waves className="w-2.5 h-2.5" /> Wavelets Signal
|
||||
</span>
|
||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||||
</div>
|
||||
{signals.length > 0 ? (
|
||||
<div className="space-y-1.5 mt-1">
|
||||
{signals.slice(0, 5).map((s: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
||||
<span className={clsx('shrink-0', s.direction === 'up' ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{s.direction === 'up' ? '↑' : '↓'}
|
||||
</span>
|
||||
<span className="font-mono text-slate-200 font-bold shrink-0">{s.ticker}</span>
|
||||
<span className="text-slate-500 truncate flex-1 text-[9px]">{s.band_label} · {s.signal_kind}</span>
|
||||
{s.computed_at && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{s.computed_at.slice(0, 10)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-slate-600 mt-1">No wavelet signal detected — le prochain cycle en calculera</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Recommandations du jour — patterns from the most recent cycle only */}
|
||||
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#84cc16']
|
||||
|
||||
// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
|
||||
const NO_CHART_EVENTS: never[] = []
|
||||
|
||||
@@ -1337,7 +1340,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
const [loadingNarr, setLoadingNarr] = useState(false)
|
||||
const [selectorOpen, setSelectorOpen] = useState(false)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>('counters')
|
||||
const [waveletLevels, setWaveletLevels] = useState(4)
|
||||
const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw')
|
||||
const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt')
|
||||
const [waveletCausal, setWaveletCausal] = useState(false)
|
||||
const [waveletLookback, setWaveletLookback] = useState(130)
|
||||
const [waveletData, setWaveletData] = useState<any | null>(null)
|
||||
const [loadingWavelet, setLoadingWavelet] = useState(false)
|
||||
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||||
@@ -1532,6 +1543,54 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
||||
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
||||
|
||||
const runWaveletAnalysis = async () => {
|
||||
if (!selected) return
|
||||
setLoadingWavelet(true)
|
||||
try {
|
||||
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
|
||||
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
|
||||
if (waveletCausal) params.lookback = waveletLookback
|
||||
const { data } = await api.get(path, { params })
|
||||
setWaveletData(data)
|
||||
setHiddenBands(new Set())
|
||||
} catch (e) {
|
||||
console.error('Wavelet analysis failed', e)
|
||||
setWaveletData(null)
|
||||
} finally {
|
||||
setLoadingWavelet(false)
|
||||
}
|
||||
}
|
||||
|
||||
const waveletChartData = useMemo(() => {
|
||||
if (!waveletData) return []
|
||||
const { dates, original, bands, mean } = waveletData
|
||||
return dates.map((d: string, i: number) => {
|
||||
const row: any = { date: d.slice(0, 10), original: original[i] }
|
||||
let recon = mean ?? 0
|
||||
for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] }
|
||||
row.reconstruction = recon
|
||||
return row
|
||||
})
|
||||
}, [waveletData])
|
||||
|
||||
const waveletStats = useMemo(() => {
|
||||
if (!waveletData || waveletChartData.length < 2) return null
|
||||
const orig: number[] = waveletData.original
|
||||
const recon = waveletChartData.map((r: any) => r.reconstruction)
|
||||
const n = orig.length
|
||||
const meanO = orig.reduce((a: number, b: number) => a + b, 0) / n
|
||||
const meanR = recon.reduce((a: number, b: number) => a + b, 0) / n
|
||||
let cov = 0, varO = 0, varR = 0, sumAbs = 0, maxAbs = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dO = orig[i] - meanO, dR = recon[i] - meanR
|
||||
cov += dO * dR; varO += dO * dO; varR += dR * dR
|
||||
const err = Math.abs(orig[i] - recon[i])
|
||||
sumAbs += err; maxAbs = Math.max(maxAbs, err)
|
||||
}
|
||||
const correlation = varO > 0 && varR > 0 ? cov / Math.sqrt(varO * varR) : null
|
||||
return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs }
|
||||
}, [waveletData, waveletChartData])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
||||
|
||||
@@ -1666,6 +1725,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
{([
|
||||
{ key: 'counters', label: 'Compteurs' },
|
||||
{ key: 'analyse', label: 'Analyse de la courbe' },
|
||||
{ key: 'wavelets', label: 'Ondelettes' },
|
||||
] as const).map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
@@ -1799,6 +1859,96 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
||||
)
|
||||
})()}
|
||||
|
||||
{tabUnder === 'wavelets' && (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Ondelette</span>
|
||||
<div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
|
||||
<label className="flex items-center gap-1 text-slate-400">
|
||||
Niveaux
|
||||
<input type="number" min={2} max={6} value={waveletLevels}
|
||||
onChange={e => setWaveletLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
|
||||
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
|
||||
</label>
|
||||
<select value={waveletFamily} onChange={e => setWaveletFamily(e.target.value as any)}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||
<option value="gmw">gmw</option>
|
||||
<option value="morlet">morlet</option>
|
||||
<option value="bump">bump</option>
|
||||
</select>
|
||||
<select value={waveletMethod} onChange={e => setWaveletMethod(e.target.value as any)}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||
<option value="cwt">CWT</option>
|
||||
<option value="ssq">SSQ</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-slate-400">
|
||||
<input type="checkbox" checked={waveletCausal} onChange={e => setWaveletCausal(e.target.checked)} />
|
||||
Mode causal
|
||||
</label>
|
||||
{waveletCausal && (
|
||||
<label className="flex items-center gap-1 text-slate-400">
|
||||
Lookback
|
||||
<input type="number" min={32} value={waveletLookback}
|
||||
onChange={e => setWaveletLookback(Math.max(32, Number(e.target.value) || 130))}
|
||||
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
|
||||
</label>
|
||||
)}
|
||||
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
|
||||
className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50">
|
||||
{loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{waveletData ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3 mb-2 text-[10px]">
|
||||
{waveletData.bands.map((b: any, i: number) => (
|
||||
<label key={b.label} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={!hiddenBands.has(b.label)}
|
||||
onChange={() => setHiddenBands(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(b.label)) next.delete(b.label); else next.add(b.label)
|
||||
return next
|
||||
})}
|
||||
/>
|
||||
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
|
||||
interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10 }} />
|
||||
<Line type="monotone" dataKey="original" stroke="#94a3b8" strokeWidth={1.5} dot={false} name="Prix" isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="reconstruction" stroke="#22c55e" strokeWidth={1} strokeDasharray="4 2" dot={false} name="Reconstruction" isAnimationActive={false} />
|
||||
{waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && (
|
||||
<Line key={b.label} type="monotone" dataKey={b.label}
|
||||
stroke={WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]}
|
||||
strokeWidth={1} dot={false} name={b.label} isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{waveletStats && (
|
||||
<div className="mt-2 flex items-center gap-4 flex-wrap text-[10px] text-slate-500">
|
||||
<span>Corrélation reconstruction: <span className="text-slate-300 font-mono">{waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'}</span></span>
|
||||
<span>Erreur moy.: <span className="text-slate-300 font-mono">{waveletStats.meanAbsError.toFixed(3)}</span></span>
|
||||
<span>Erreur max: <span className="text-slate-300 font-mono">{waveletStats.maxAbsError.toFixed(3)}</span></span>
|
||||
{waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) — {waveletData.recomputations} recalculs</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500 text-center py-10">
|
||||
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NarrativeCard
|
||||
narrative={narrative}
|
||||
loading={loadingNarr}
|
||||
|
||||
494
frontend/src/pages/WaveletsSimulation.tsx
Normal file
494
frontend/src/pages/WaveletsSimulation.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Waves, Play, Save, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../hooks/useApi'
|
||||
import {
|
||||
WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult,
|
||||
WAVELET_TRIGGER_KINDS, runFullOptimization, formatBandIndex,
|
||||
} from '../lib/waveletTrade'
|
||||
|
||||
const ASSET_CLASS_LABELS: Record<string, string> = {
|
||||
energy: 'Énergie', metals: 'Métaux', agriculture: 'Agriculture',
|
||||
indices: 'Indices', etfs: 'ETFs', equities: 'Actions', forex: 'Forex',
|
||||
}
|
||||
const WAVELET_FAMILIES = ['gmw', 'morlet', 'bump'] as const
|
||||
const ALL_MODES: WaveletPositionMode[] = ['long_only', 'short_only', 'both']
|
||||
const MODE_LABELS: Record<WaveletPositionMode, string> = { long_only: 'Long', short_only: 'Short', both: 'Les deux' }
|
||||
const ALL_BANDS = [0, 1, 2, 3, 4, 5]
|
||||
|
||||
function ChipInput({ values, onChange, placeholder, min = 0 }: { values: number[]; onChange: (v: number[]) => void; placeholder: string; min?: number }) {
|
||||
const [input, setInput] = useState('')
|
||||
const add = () => {
|
||||
const n = Number(input)
|
||||
if (!isNaN(n) && n >= min && !values.includes(n)) onChange([...values, n].sort((a, b) => a - b))
|
||||
setInput('')
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{values.map(v => (
|
||||
<span key={v} className="flex items-center gap-1 bg-dark-700 border border-slate-700/40 rounded px-1.5 py-0.5 text-[10px] text-slate-300">
|
||||
{v}
|
||||
<button onClick={() => onChange(values.filter(x => x !== v))} className="text-slate-600 hover:text-red-400">
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={input} onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && add()}
|
||||
placeholder={placeholder}
|
||||
className="w-16 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-[10px] text-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WaveletsSimulation() {
|
||||
const qc = useQueryClient()
|
||||
const [subTab, setSubTab] = useState<'optimisation' | 'summary'>('optimisation')
|
||||
|
||||
const { data: watchlistUniverse } = useQuery({
|
||||
queryKey: ['wavelet-sim-instruments'],
|
||||
queryFn: () => api.get('/market/watchlist').then(r => r.data as Record<string, { symbol: string; name: string; currency: string }[]>),
|
||||
staleTime: 60 * 60_000,
|
||||
})
|
||||
|
||||
// ── Form state ──────────────────────────────────────────────────────────────
|
||||
const [selectedInstruments, setSelectedInstruments] = useState<Set<string>>(new Set())
|
||||
const [period, setPeriod] = useState('2y')
|
||||
const [levels, setLevels] = useState(4)
|
||||
const [lookbacks, setLookbacks] = useState<number[]>([90, 130])
|
||||
const [wavelets, setWavelets] = useState<Set<string>>(new Set(['gmw']))
|
||||
const [method, setMethod] = useState<'cwt' | 'ssq'>('cwt')
|
||||
const [bands, setBands] = useState<Set<number>>(new Set([0, 1, 2, 3]))
|
||||
const [kinds, setKinds] = useState<Set<WaveletTriggerKind>>(new Set(['extremum', 'level_threshold']))
|
||||
const [modes, setModes] = useState<Set<WaveletPositionMode>>(new Set(['both']))
|
||||
const [takeProfits, setTakeProfits] = useState<number[]>([0])
|
||||
const [stopLosses, setStopLosses] = useState<number[]>([0])
|
||||
const [simName, setSimName] = useState(() => `Run ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`)
|
||||
|
||||
// ── Run state ────────────────────────────────────────────────────────────────
|
||||
const [running, setRunning] = useState(false)
|
||||
const [progress, setProgress] = useState<{ done: number; total: number; label: string } | null>(null)
|
||||
const [failures, setFailures] = useState<string[]>([])
|
||||
const [results, setResults] = useState<WaveletOptimizationResult[]>([])
|
||||
const [activeSimId, setActiveSimId] = useState<string | null>(null)
|
||||
|
||||
// ── Results table filters ───────────────────────────────────────────────────
|
||||
const [minTrades, setMinTrades] = useState(1)
|
||||
const [sortKey, setSortKey] = useState<'totalReturnPct' | 'winRate' | 'tradeCount'>('totalReturnPct')
|
||||
const [expandedRow, setExpandedRow] = useState<number | null>(null)
|
||||
|
||||
const { data: savedSims } = useQuery({
|
||||
queryKey: ['wavelet-simulations'],
|
||||
queryFn: () => api.get('/wavelet/simulations').then(r => r.data as any[]),
|
||||
})
|
||||
|
||||
const toggleSetValue = <T,>(set: Set<T>, v: T, setter: (s: Set<T>) => void) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(v)) next.delete(v); else next.add(v)
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const runOptimization = async () => {
|
||||
if (selectedInstruments.size === 0 || lookbacks.length === 0 || wavelets.size === 0 || kinds.size === 0) return
|
||||
setRunning(true)
|
||||
setFailures([])
|
||||
setResults([])
|
||||
setExpandedRow(null)
|
||||
|
||||
// Create the persisted run up-front so results can be appended incrementally —
|
||||
// a run spanning many combos shouldn't lose progress to a refresh/interruption.
|
||||
const form = {
|
||||
instruments: [...selectedInstruments], period, levels, wavelets: [...wavelets],
|
||||
lookbacks, bands: [...bands], kinds: [...kinds], modes: [...modes],
|
||||
takeProfits, stopLosses, method,
|
||||
}
|
||||
const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data)
|
||||
setActiveSimId(created.id)
|
||||
|
||||
const exitVariants: WaveletExitVariant[] = []
|
||||
for (const tp of takeProfits) for (const sl of stopLosses) {
|
||||
exitVariants.push({ exitStyle: tp === 0 && sl === 0 ? 'signal' : 'signal_or_pct', takeProfitPct: tp || null, stopLossPct: sl || null })
|
||||
}
|
||||
|
||||
const allResults: WaveletOptimizationResult[] = []
|
||||
await runFullOptimization(
|
||||
[...selectedInstruments], lookbacks, [...wavelets], period, levels,
|
||||
[...bands], [...kinds], [...modes], exitVariants,
|
||||
(done, total, label, fails) => { setProgress({ done, total, label }); setFailures([...fails]) },
|
||||
async (batch) => {
|
||||
allResults.push(...batch)
|
||||
setResults([...allResults])
|
||||
try { await api.patch(`/wavelet/simulations/${created.id}`, { append_results: batch }) } catch { /* non-blocking */ }
|
||||
},
|
||||
method,
|
||||
)
|
||||
setRunning(false)
|
||||
qc.invalidateQueries({ queryKey: ['wavelet-simulations'] })
|
||||
}
|
||||
|
||||
const loadSimulation = async (id: string) => {
|
||||
const sim = await api.get(`/wavelet/simulations/${id}`).then(r => r.data)
|
||||
setResults(sim.results ?? [])
|
||||
setActiveSimId(id)
|
||||
setSimName(sim.name)
|
||||
}
|
||||
|
||||
const deleteSimulation = async (id: string) => {
|
||||
await api.delete(`/wavelet/simulations/${id}`)
|
||||
if (activeSimId === id) { setActiveSimId(null); setResults([]) }
|
||||
qc.invalidateQueries({ queryKey: ['wavelet-simulations'] })
|
||||
}
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
return [...results]
|
||||
.filter(r => r.tradeCount >= minTrades)
|
||||
.sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number))
|
||||
}, [results, minTrades, sortKey])
|
||||
|
||||
const perSymbolBreakdown = (r: WaveletOptimizationResult) =>
|
||||
results.filter(x => x.symbol === r.symbol && x.lookback === r.lookback && x.wavelet === r.wavelet
|
||||
&& x.triggerKind === r.triggerKind && x.bandIndex === r.bandIndex && x.mode === r.mode
|
||||
&& x.exitStyle === r.exitStyle && x.takeProfitPct === r.takeProfitPct && x.stopLossPct === r.stopLossPct)
|
||||
|
||||
const configSummary = useMemo(() => {
|
||||
const symbols = new Set(results.map(r => r.symbol))
|
||||
const totalReturn = results.reduce((s, r) => s + r.totalReturnPct, 0)
|
||||
const avgWinRate = results.length ? results.reduce((s, r) => s + r.winRate, 0) / results.length : 0
|
||||
return { configs: results.length, symbols: symbols.size, totalReturn, avgWinRate }
|
||||
}, [results])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Waves className="w-5 h-5 text-blue-400" /> Wavelets Simulation
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Décomposition en bandes de fréquence (CWT/SSQ) + backtest multi-instruments — portage de InstrumentSimulator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||||
{(['optimisation', 'summary'] as const).map(k => (
|
||||
<button key={k} onClick={() => setSubTab(k)}
|
||||
className={clsx('px-3 py-1.5 rounded text-sm transition-colors capitalize',
|
||||
subTab === k ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||
{k === 'optimisation' ? 'Optimisation' : 'Summary'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subTab === 'optimisation' && (
|
||||
<div className="space-y-4">
|
||||
{/* ── Config form ── */}
|
||||
<div className="card space-y-4">
|
||||
<div>
|
||||
<div className="section-title mb-2">Instruments</div>
|
||||
<div className="grid grid-cols-4 gap-3 max-h-56 overflow-y-auto">
|
||||
{Object.entries(watchlistUniverse ?? {}).map(([cls, items]) => (
|
||||
<div key={cls}>
|
||||
<div className="text-[10px] text-slate-500 uppercase mb-1">{ASSET_CLASS_LABELS[cls] ?? cls}</div>
|
||||
<div className="space-y-0.5">
|
||||
{items.map(it => (
|
||||
<label key={it.symbol} className="flex items-center gap-1.5 text-[10px] text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedInstruments.has(it.symbol)}
|
||||
onChange={() => toggleSetValue(selectedInstruments, it.symbol, setSelectedInstruments)} />
|
||||
<span className="truncate">{it.symbol}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-600 mt-1">{selectedInstruments.size} instrument(s) sélectionné(s)</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Période</div>
|
||||
<select value={period} onChange={e => setPeriod(e.target.value)}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full">
|
||||
{['6mo', '1y', '2y', '5y'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Niveaux</div>
|
||||
<input type="number" min={2} max={6} value={levels}
|
||||
onChange={e => setLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Méthode</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
{(['cwt', 'ssq'] as const).map(m => (
|
||||
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={method === m} onChange={() => setMethod(m)} /> {m.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Nom de la simulation</div>
|
||||
<input value={simName} onChange={e => setSimName(e.target.value)}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Fenêtres glissantes à tester (jours, ≥32)</div>
|
||||
<ChipInput values={lookbacks} onChange={setLookbacks} placeholder="130" min={32} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Ondelettes</div>
|
||||
<div className="flex gap-3">
|
||||
{WAVELET_FAMILIES.map(w => (
|
||||
<label key={w} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={wavelets.has(w)} onChange={() => toggleSetValue(wavelets, w, setWavelets)} /> {w}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Bandes à tester</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ALL_BANDS.slice(0, levels).map(b => (
|
||||
<label key={b} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={bands.has(b)} onChange={() => toggleSetValue(bands, b, setBands)} /> {b}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Modes à tester</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ALL_MODES.map(m => (
|
||||
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={modes.has(m)} onChange={() => toggleSetValue(modes, m, setModes)} /> {MODE_LABELS[m]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Take-profit / Stop-loss % (0 = aucun)</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ChipInput values={takeProfits} onChange={setTakeProfits} placeholder="TP%" />
|
||||
<ChipInput values={stopLosses} onChange={setStopLosses} placeholder="SL%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs mb-1">Types de déclencheur</div>
|
||||
<div className="flex gap-3 flex-wrap text-xs">
|
||||
{WAVELET_TRIGGER_KINDS.filter(k => method === 'ssq' || (k.value !== 'ridge_shift' && k.value !== 'energy_threshold')).map(k => (
|
||||
<label key={k.value} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={kinds.has(k.value)} onChange={() => toggleSetValue(kinds, k.value, setKinds)} />
|
||||
{k.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={runOptimization} disabled={running || selectedInstruments.size === 0}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-40 transition-colors">
|
||||
<Play className="w-4 h-4" /> {running ? 'Optimisation en cours…' : "Lancer l'optimisation"}
|
||||
</button>
|
||||
|
||||
{progress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-500">
|
||||
<span>{progress.label}</span>
|
||||
<span>{progress.done}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="bg-dark-700 rounded-full h-1.5">
|
||||
<div className="bg-blue-500 h-1.5 rounded-full transition-all" style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
{failures.length > 0 && (
|
||||
<div className="text-[10px] text-red-400">{failures.length} échec(s) : {failures.slice(0, 3).join(' · ')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Saved simulations ── */}
|
||||
<div className="card">
|
||||
<div className="section-title mb-2">Simulations sauvegardées</div>
|
||||
{(savedSims ?? []).length === 0 ? (
|
||||
<div className="text-xs text-slate-600">Aucune simulation sauvegardée.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{savedSims!.map(s => (
|
||||
<div key={s.id} className={clsx('flex items-center justify-between px-2 py-1.5 rounded text-xs',
|
||||
activeSimId === s.id ? 'bg-blue-900/20 border border-blue-700/30' : 'bg-dark-800/60')}>
|
||||
<div>
|
||||
<span className="text-slate-200 font-medium">{s.name}</span>
|
||||
<span className="text-slate-600 ml-2">{s.result_count} résultats · {s.updated_at?.slice(0, 16)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => loadSimulation(s.id)} className="text-blue-400 hover:text-blue-300">Charger</button>
|
||||
<button onClick={() => deleteSimulation(s.id)} className="text-slate-600 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Results ── */}
|
||||
{results.length > 0 && (
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400 flex-wrap">
|
||||
<span>{configSummary.configs} configs testées</span>
|
||||
<span>{configSummary.symbols} instrument(s)</span>
|
||||
<span>Retour cumulé: <span className={configSummary.totalReturn >= 0 ? 'text-emerald-400' : 'text-red-400'}>{configSummary.totalReturn.toFixed(1)}%</span></span>
|
||||
<span>Win rate moy.: {(configSummary.avgWinRate * 100).toFixed(0)}%</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label className="flex items-center gap-1">Min. trades
|
||||
<input type="number" min={0} value={minTrades} onChange={e => setMinTrades(Number(e.target.value) || 0)}
|
||||
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1 py-0.5 text-white" />
|
||||
</label>
|
||||
<select value={sortKey} onChange={e => setSortKey(e.target.value as any)}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||
<option value="totalReturnPct">Trier: Retour</option>
|
||||
<option value="winRate">Trier: Win rate</option>
|
||||
<option value="tradeCount">Trier: Nb trades</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500 border-b border-slate-700/30 text-left">
|
||||
<th className="py-1.5 pr-3">Symbole</th>
|
||||
<th className="pr-3">Lookback</th>
|
||||
<th className="pr-3">Ondelette</th>
|
||||
<th className="pr-3">Bande</th>
|
||||
<th className="pr-3">Trigger</th>
|
||||
<th className="pr-3">Mode</th>
|
||||
<th className="pr-3 text-right">Retour</th>
|
||||
<th className="pr-3 text-right">Win rate</th>
|
||||
<th className="pr-3 text-right">Trades</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResults.slice(0, 100).map((r, i) => (
|
||||
<Fragment key={i}>
|
||||
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)}
|
||||
className="border-b border-slate-800/40 hover:bg-dark-800/40 cursor-pointer">
|
||||
<td className="py-1 pr-3 font-mono text-slate-200">{r.symbol}</td>
|
||||
<td className="pr-3 text-slate-400">{r.lookback}j</td>
|
||||
<td className="pr-3 text-slate-400">{r.wavelet}</td>
|
||||
<td className="pr-3 text-slate-400">{formatBandIndex(r.bandIndex)}</td>
|
||||
<td className="pr-3 text-slate-400">{r.triggerKind}</td>
|
||||
<td className="pr-3 text-slate-400">{MODE_LABELS[r.mode]}</td>
|
||||
<td className={clsx('pr-3 text-right font-mono font-bold', r.totalReturnPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{r.totalReturnPct >= 0 ? '+' : ''}{r.totalReturnPct.toFixed(1)}%
|
||||
</td>
|
||||
<td className="pr-3 text-right font-mono text-slate-300">{(r.winRate * 100).toFixed(0)}%</td>
|
||||
<td className="pr-3 text-right font-mono text-slate-300">{r.tradeCount}</td>
|
||||
</tr>
|
||||
{expandedRow === i && (
|
||||
<tr key={`${i}-detail`} className="bg-dark-900/40">
|
||||
<td colSpan={9} className="p-2">
|
||||
<div className="text-[10px] text-slate-500 mb-1">Détail par instrument (même config)</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{perSymbolBreakdown(r).map((d, j) => (
|
||||
<div key={j} className="flex items-center justify-between bg-dark-800/60 rounded px-2 py-1 text-[10px]">
|
||||
<span className="font-mono text-slate-300">{d.symbol}</span>
|
||||
<span className={clsx('font-mono', d.totalReturnPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{d.totalReturnPct >= 0 ? '+' : ''}{d.totalReturnPct.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-slate-600">{d.tradeCount}tr</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'summary' && <WaveletSummaryTab savedSims={savedSims ?? []} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WaveletSummaryTab({ savedSims }: { savedSims: any[] }) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [aggregate, setAggregate] = useState<{ configs: number; avgReturn: number; avgWinRate: number; avgTrades: number } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
setSelectedIds(next)
|
||||
}
|
||||
|
||||
const computeAggregate = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const all: WaveletOptimizationResult[] = []
|
||||
for (const id of selectedIds) {
|
||||
const sim = await api.get(`/wavelet/simulations/${id}`).then(r => r.data)
|
||||
all.push(...(sim.results ?? []))
|
||||
}
|
||||
if (all.length === 0) { setAggregate(null); return }
|
||||
setAggregate({
|
||||
configs: all.length,
|
||||
avgReturn: all.reduce((s, r) => s + r.totalReturnPct, 0) / all.length,
|
||||
avgWinRate: all.reduce((s, r) => s + r.winRate, 0) / all.length,
|
||||
avgTrades: all.reduce((s, r) => s + r.tradeCount, 0) / all.length,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card space-y-3">
|
||||
<div className="section-title">Sélectionner des runs à agréger</div>
|
||||
{savedSims.length === 0 ? (
|
||||
<div className="text-xs text-slate-600">Aucune simulation sauvegardée.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{savedSims.map(s => (
|
||||
<label key={s.id} className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" checked={selectedIds.has(s.id)} onChange={() => toggle(s.id)} />
|
||||
{s.name} <span className="text-slate-600">({s.result_count} résultats)</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={computeAggregate} disabled={loading || selectedIds.size === 0}
|
||||
className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium disabled:opacity-40">
|
||||
{loading ? 'Calcul…' : 'Agréger'}
|
||||
</button>
|
||||
{aggregate && (
|
||||
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700/30 text-xs">
|
||||
<div><div className="text-slate-600">Configs</div><div className="text-slate-200 font-mono">{aggregate.configs}</div></div>
|
||||
<div><div className="text-slate-600">Retour moy.</div><div className={clsx('font-mono', aggregate.avgReturn >= 0 ? 'text-emerald-400' : 'text-red-400')}>{aggregate.avgReturn.toFixed(2)}%</div></div>
|
||||
<div><div className="text-slate-600">Win rate moy.</div><div className="text-slate-200 font-mono">{(aggregate.avgWinRate * 100).toFixed(1)}%</div></div>
|
||||
<div><div className="text-slate-600">Trades moy.</div><div className="text-slate-200 font-mono">{aggregate.avgTrades.toFixed(1)}</div></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user