feat: cockpit

This commit is contained in:
OpenSquared
2026-07-24 15:52:58 +02:00
parent 3ec0c024ad
commit 7cb81cbd65
6 changed files with 211 additions and 19 deletions

View File

@@ -3,12 +3,13 @@ import { Link } from 'react-router-dom'
import { ArrowUpRight } from 'lucide-react'
import clsx from 'clsx'
export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
export default function TradeRankList({ trades, mode, title, linkTo, toggle, tickerLabel }: {
trades: any[]
mode: 'top' | 'worst' | 'recent'
title: string
linkTo: string
toggle?: ReactNode
tickerLabel?: (ticker: string) => string
}) {
const sorted = [...trades]
.sort((a, b) => mode === 'recent'
@@ -16,7 +17,6 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
: mode === 'top'
? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999)
: (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999))
.slice(0, 5)
return (
<Link to={linkTo} className="card block hover:border-slate-600/60 transition-all cursor-pointer">
@@ -28,7 +28,7 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
</div>
</div>
{sorted.length > 0 ? (
<div className="space-y-1.5 mt-1">
<div className="space-y-1.5 mt-1 max-h-[135px] overflow-y-auto pr-0.5">
{sorted.map((t: any, i: number) => {
const pnl: number | null = t.pnl_pct ?? null
const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
@@ -37,7 +37,9 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
<div key={i} className="flex items-center gap-1.5 text-[10px]">
<span className="text-[9px] text-slate-700 font-mono w-3 shrink-0">{i + 1}</span>
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
<span className="font-mono text-slate-200 font-bold shrink-0">{t.underlying ?? '—'}</span>
<span className="font-mono text-slate-200 font-bold shrink-0">
{t.underlying ? (tickerLabel ? tickerLabel(t.underlying) : t.underlying) : '—'}
</span>
<span className="text-slate-500 truncate flex-1 text-[9px]">{t.strategy ?? ''}</span>
{entryDate && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{entryDate}</span>}
{pnl !== null ? (

View File

@@ -213,6 +213,31 @@ export default function Dashboard() {
const lastCycle = (cycleStatusData as any)?.last_cycle ?? null
const openPositions: any[] = (openPositionsData as any) ?? []
// Trades Overview shows each position's trading-side underlying (yfinance/futures
// convention: ^GSPC, ^NDX, GC=F, HG=F, EURUSD=X...), which reads as noise next to the
// Cockpit's own friendly Watchlist names for the exact same instrument (SP500, NASDAQ,
// GOLD, COPPER, EURUSD). Rename to the Watchlist ticker when one plausibly matches —
// suffix-stripped (EURUSD=X -> EURUSD) or via a small curated root-ticker alias table —
// and only if that name is actually in the current Watchlist (not just "known"), so this
// stays tied to what's configured rather than a static list.
const UNDERLYING_ALIASES: Record<string, string> = {
'^GSPC': 'SP500', '^NDX': 'NASDAQ', '^DJI': 'DOW', '^RUT': 'RUSSELL2000',
'GC=F': 'GOLD', 'SI=F': 'SILVER', 'HG=F': 'COPPER', 'PL=F': 'PLATINUM',
'CL=F': 'CRUDE', 'BZ=F': 'BRENT', 'NG=F': 'NATGAS',
'ZW=F': 'WHEAT', 'ZC=F': 'CORN', 'ZS=F': 'SOYBEANS',
}
const underlyingDisplayName = (underlying: string): string => {
if (!underlying) return underlying
const upper = underlying.toUpperCase()
const watchlistTickers = new Set(((watchlistItems as any[]) ?? []).map(w => w.ticker.toUpperCase()))
const baseTicker = upper.replace(/(=X|=F)$/, '')
if (watchlistTickers.has(upper)) return upper
if (watchlistTickers.has(baseTicker)) return baseTicker
const alias = UNDERLYING_ALIASES[upper]
if (alias && watchlistTickers.has(alias)) return alias
return underlying
}
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
const cyclePatterns = useMemo(() => {
const started = lastCycle?.started_at ?? null
@@ -1013,7 +1038,7 @@ export default function Dashboard() {
{/* ── Command Center: Intelligence & Contexte ── */}
<div className="grid grid-cols-4 gap-3">
<TradeRankList trades={openPositions} mode="recent" title="🏆 Trades Overview" linkTo="/portfolio" />
<TradeRankList trades={openPositions} mode="recent" title="🏆 Trades Overview" linkTo="/portfolio" tickerLabel={underlyingDisplayName} />
{/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each
cycle). Single click on the card = Wavelets Simulation overview; double-click a
@@ -1035,7 +1060,10 @@ export default function Dashboard() {
{signals.slice(0, 5).map((s: any, i: number) => (
<div
key={i}
onDoubleClick={() => navigate(`/instruments/${encodeURIComponent(s.ticker)}?tab=wavelets`)}
onDoubleClick={() => quickAddInstrument.mutate(s.ticker, {
onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}?tab=wavelets`),
onError: (e) => console.error(`[WaveletsSignal] failed to open ${s.ticker} in Instrument Analysis:`, e),
})}
title="Double-click to open in Instrument Analysis → Wavelets"
className="flex items-center gap-1.5 text-[10px] rounded px-1 -mx-1 py-0.5 cursor-pointer hover:bg-dark-700/40 transition-colors"
>

View File

@@ -1456,6 +1456,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [waveletCausal, setWaveletCausal] = useState(false)
const [waveletLookback, setWaveletLookback] = useState(260)
const [waveletData, setWaveletData] = useState<any | null>(null)
// 'cached' = last per-cycle scan result (services.wavelet_signals, same source as the
// Dashboard's Wavelets Signal card) — loaded automatically. 'live' = a fresh on-demand
// run from the "Start analyse" button, with whatever params are set above; not persisted
// back into the cache, so it can diverge from what the Signal card shows until the next cycle.
const [waveletSource, setWaveletSource] = useState<'cached' | 'live' | null>(null)
const [waveletCachedAt, setWaveletCachedAt] = useState<string | null>(null)
const [loadingWavelet, setLoadingWavelet] = useState(false)
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
const [loadingReliability, setLoadingReliability] = useState(false)
@@ -1655,6 +1661,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
// On-demand LIVE recompute — the "Start analyse" button only. Does not touch
// wavelet_decomposition_cache, so it can show a different result (custom params, more
// recent tick) than the cached view without disturbing what the Dashboard's Wavelets
// Signal card / other instruments' cached tabs show until the next scheduled cycle.
const runWaveletAnalysis = async () => {
if (!selected) return
setLoadingWavelet(true)
@@ -1665,15 +1675,47 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
if (waveletCausal) params.lookback = waveletLookback
const { data } = await api.get(path, { params })
setWaveletData(data)
setWaveletSource('live')
setWaveletCachedAt(null)
setHiddenBands(new Set())
} catch (e) {
console.error('Wavelet analysis failed', e)
setWaveletData(null)
setWaveletSource(null)
} finally {
setLoadingWavelet(false)
}
}
// Load the cached decomposition once per instrument, the first time the Wavelet tab
// becomes active for it — NOT a live call. Same data the per-cycle Watchlist scan writes
// (services.wavelet_signals.scan_watchlist_wavelet_signals), so this tab always agrees
// with the Dashboard's Wavelets Signal card and never needs a click just to show the
// current state. 404 (no cycle has scanned this ticker yet, or it's not Watchlist-linked)
// just leaves the empty state up — "Start analyse" still runs a live one on demand.
// Keyed by instrumentId in a ref so tweaking levels/family/method for a live run
// afterward doesn't silently re-trigger this.
const waveletAutoFetchKey = useRef<string | null>(null)
useEffect(() => {
if (tabUnder !== 'wavelets' || !selected) return
if (waveletAutoFetchKey.current === instrumentId) return
waveletAutoFetchKey.current = instrumentId
setWaveletData(null)
setWaveletSource(null)
setWaveletCachedAt(null)
setLoadingWavelet(true)
api.get(`/instruments/${encodeURIComponent(instrumentId)}/wavelet-cache`)
.then(({ data }) => {
setWaveletData(data)
setWaveletSource('cached')
setWaveletCachedAt(data.computed_at ?? null)
setHiddenBands(new Set())
})
.catch(() => { /* no cache yet — expected, empty state stays up */ })
.finally(() => setLoadingWavelet(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabUnder, instrumentId, selected])
const runWaveletReliability = async () => {
if (!selected) return
setLoadingReliability(true)
@@ -2080,7 +2122,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{([
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
{ key: 'wavelets', label: 'Ondelettes' },
{ key: 'wavelets', label: 'Wavelet' },
] as const).map(t => (
<button
key={t.key}
@@ -2217,7 +2259,19 @@ 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>
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Wavelet</span>
{waveletSource === 'cached' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-900/30 border border-emerald-700/30 text-emerald-400"
title="Dernier calcul du cycle automatique — même source que la card Wavelets Signal du Cockpit">
En cache{waveletCachedAt ? ` · ${waveletCachedAt.slice(0, 16).replace('T', ' ')}` : ''}
</span>
)}
{waveletSource === 'live' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/30 border border-blue-700/30 text-blue-400"
title="Calcul à la demande avec les paramètres ci-contre — pas persisté, n'affecte pas la card Wavelets Signal">
Calcul live
</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
@@ -2249,8 +2303,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</label>
)}
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
title="Les bandes se chargent automatiquement à l'ouverture de l'onglet — ce bouton force un nouveau calcul (ex. après avoir changé les paramètres)."
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"}
{loadingWavelet ? '↻ Calcul…' : '▶ Start analyse'}
</button>
<button onClick={runWaveletReliability} disabled={loadingReliability}
title="Pour chaque retournement détecté en mode causal (walk-forward, sans anticipation), vérifie si un recalcul 10 jours plus tard confirme le même retournement — un indice de fiabilité par bande."
@@ -2461,7 +2516,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</>
) : (
<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"}
{loadingWavelet ? 'Calcul en cours…' : 'Pas encore de décomposition en cache pour cet instrument (attend le prochain cycle) — "Start analyse" pour un calcul live'}
</div>
)}