feat: cockpit
This commit is contained in:
@@ -72,6 +72,34 @@ async def instrument_snapshot(
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instrument_id}/wavelet-cache")
|
||||||
|
def wavelet_cache(instrument_id: str) -> Dict[str, Any]:
|
||||||
|
"""Latest full band decomposition for this instrument, from the same per-cycle scan
|
||||||
|
that feeds the Dashboard's Wavelets Signal card (services.wavelet_signals.
|
||||||
|
scan_watchlist_wavelet_signals) — NOT a live recompute. Instrument Analysis's Wavelet
|
||||||
|
tab reads this by default so opening it never triggers its own analysis and always
|
||||||
|
agrees with the Signal card; "Start analyse" is still there for an on-demand live run
|
||||||
|
with custom parameters, which does NOT overwrite this cache.
|
||||||
|
|
||||||
|
Looked up by the Cockpit Watchlist ticker the cache is keyed on, normalized via the
|
||||||
|
same yfinance-suffix stripping instrument_service uses elsewhere (EURUSD=X -> EURUSD)
|
||||||
|
— a quick-added instrument's id already equals its Watchlist ticker verbatim, but a
|
||||||
|
curated one (EURUSD=X) doesn't."""
|
||||||
|
from services.instrument_service import _base_ticker
|
||||||
|
from services.database import get_wavelet_decomposition_cache
|
||||||
|
cached = get_wavelet_decomposition_cache(_base_ticker(instrument_id))
|
||||||
|
if not cached or not cached.get("decomposition"):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Aucune décomposition wavelet en cache pour '{instrument_id}'")
|
||||||
|
return {
|
||||||
|
"computed_at": cached["computed_at"],
|
||||||
|
"method": cached["method"],
|
||||||
|
"wavelet": cached["wavelet"],
|
||||||
|
"num_levels": cached["num_levels"],
|
||||||
|
"lookback_days": cached["lookback_days"],
|
||||||
|
**cached["decomposition"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instrument_id}/narrative")
|
@router.post("/{instrument_id}/narrative")
|
||||||
async def generate_narrative(
|
async def generate_narrative(
|
||||||
instrument_id: str,
|
instrument_id: str,
|
||||||
|
|||||||
@@ -223,6 +223,22 @@ def init_db():
|
|||||||
price_at_signal REAL
|
price_at_signal REAL
|
||||||
)""",
|
)""",
|
||||||
"ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0",
|
"ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0",
|
||||||
|
# Wavelets — full band-decomposition series from the same per-cycle watchlist scan
|
||||||
|
# (see wavelet_watchlist_signals above, which only keeps the latest scalar
|
||||||
|
# slope/energy/signal per band). One row per ticker, overwritten every cycle — this
|
||||||
|
# is what Instrument Analysis's Wavelet tab reads by default so opening it doesn't
|
||||||
|
# trigger a live recompute (services.wavelet_signals.scan_watchlist_wavelet_signals
|
||||||
|
# writes both tables together from the exact same decomposition run).
|
||||||
|
"""CREATE TABLE IF NOT EXISTS wavelet_decomposition_cache (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT,
|
||||||
|
computed_at TEXT DEFAULT (datetime('now')),
|
||||||
|
method TEXT,
|
||||||
|
wavelet TEXT,
|
||||||
|
num_levels INTEGER,
|
||||||
|
lookback_days REAL,
|
||||||
|
decomposition_json TEXT
|
||||||
|
)""",
|
||||||
# AI Chat widget — persisted conversation turns, one growing thread per session_id
|
# AI Chat widget — persisted conversation turns, one growing thread per session_id
|
||||||
"""CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
"""CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -3631,6 +3647,40 @@ def get_wavelet_signals_history(ticker: str, days: int = 30) -> List[Dict]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def save_wavelet_decomposition_cache(
|
||||||
|
ticker: str, run_id: Optional[str], method: str, wavelet: str,
|
||||||
|
num_levels: int, lookback_days: float, decomposition: Dict,
|
||||||
|
) -> None:
|
||||||
|
"""Overwrites this ticker's cached full band-decomposition — one row per ticker, not a
|
||||||
|
history (services.wavelet_signals.scan_watchlist_wavelet_signals calls this once per
|
||||||
|
cycle, right after computing the same decomposition object it also flattens into
|
||||||
|
wavelet_watchlist_signals)."""
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO wavelet_decomposition_cache (ticker, run_id, computed_at, method, wavelet, num_levels, lookback_days, decomposition_json) "
|
||||||
|
"VALUES (?, ?, datetime('now'), ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(ticker) DO UPDATE SET run_id=excluded.run_id, computed_at=excluded.computed_at, method=excluded.method, "
|
||||||
|
"wavelet=excluded.wavelet, num_levels=excluded.num_levels, lookback_days=excluded.lookback_days, "
|
||||||
|
"decomposition_json=excluded.decomposition_json",
|
||||||
|
(ticker.upper(), run_id, method, wavelet, num_levels, lookback_days, json.dumps(decomposition)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_wavelet_decomposition_cache(ticker: str) -> Optional[Dict]:
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM wavelet_decomposition_cache WHERE ticker = ? COLLATE NOCASE", (ticker,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["decomposition"] = json.loads(d.pop("decomposition_json")) if d.get("decomposition_json") else None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
# ── AI Chat widget — persisted conversation ────────────────────────────────────
|
# ── AI Chat widget — persisted conversation ────────────────────────────────────
|
||||||
|
|
||||||
def save_chat_message(session_id: str, role: str, content: str) -> None:
|
def save_chat_message(session_id: str, role: str, content: str) -> None:
|
||||||
|
|||||||
@@ -231,7 +231,33 @@ def _technical_desk_wavelet_config() -> Dict:
|
|||||||
return (desk.get("config") or {}).get("signals") or {}
|
return (desk.get("config") or {}).get("signals") or {}
|
||||||
|
|
||||||
|
|
||||||
def scan_watchlist_wavelet_signals() -> List[Dict]:
|
def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]):
|
||||||
|
"""Saxo-first when this watchlist instrument has a saxo_quote_symbol link, yfinance
|
||||||
|
otherwise or as a silent fallback on any Saxo failure — same pattern as
|
||||||
|
routers/wavelet.py's _fetch_history, duplicated locally rather than importing across a
|
||||||
|
router boundary. Needed because several Watchlist instruments (BRENT, COPPER...) have no
|
||||||
|
real yfinance ticker at all — get_historical(ticker, ...) always failed for them, which
|
||||||
|
silently dropped them out of the per-cycle scan entirely (one bad ticker just gets
|
||||||
|
skipped, see the try/except around the caller) — that's why the Wavelets Signal card
|
||||||
|
only ever showed the yfinance-recognized subset of the Watchlist."""
|
||||||
|
if saxo_symbol:
|
||||||
|
try:
|
||||||
|
from services.database import get_saxo_catalog_by_symbol
|
||||||
|
from services.saxo_client import get_price_history
|
||||||
|
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
||||||
|
asset_type = entry["asset_type"] if entry else "FxSpot"
|
||||||
|
bars = get_price_history(saxo_symbol, asset_type, days=400)
|
||||||
|
return [b["close"] for b in bars], [b["date"] for b in bars]
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"[wavelet_signals] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}")
|
||||||
|
|
||||||
|
from services.data_fetcher import get_historical
|
||||||
|
hist = get_historical(ticker, period="1y", interval="1d")
|
||||||
|
return [h["close"] for h in hist], [h["date"] for h in hist]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_watchlist_wavelet_signals(run_id: Optional[str] = None) -> List[Dict]:
|
||||||
"""Compute a causal (no-look-ahead) band decomposition for each watchlist
|
"""Compute a causal (no-look-ahead) band decomposition for each watchlist
|
||||||
instrument. Every (ticker, band) gets a row every cycle — current slope/
|
instrument. Every (ticker, band) gets a row every cycle — current slope/
|
||||||
value/energy state always, plus signal_kind/direction/params_json when one
|
value/energy state always, plus signal_kind/direction/params_json when one
|
||||||
@@ -239,9 +265,13 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
|||||||
wins, evaluated extremum -> level_threshold -> trend_flatten ->
|
wins, evaluated extremum -> level_threshold -> trend_flatten ->
|
||||||
acceleration -> band_cross -> energy_threshold). ridge_shift is evaluated
|
acceleration -> band_cross -> energy_threshold). ridge_shift is evaluated
|
||||||
once per ticker (not per band — the ridge is a single track for the whole
|
once per ticker (not per band — the ridge is a single track for the whole
|
||||||
decomposition) and stored as an extra band_label="ridge" row."""
|
decomposition) and stored as an extra band_label="ridge" row.
|
||||||
from services.database import get_instruments_watchlist
|
|
||||||
from services.data_fetcher import get_historical
|
Also caches the full (untruncated) decomposition per ticker in
|
||||||
|
wavelet_decomposition_cache — Instrument Analysis's Wavelet tab reads that instead of
|
||||||
|
running its own live decomposition on open, so it always agrees with the Watchlist
|
||||||
|
Signal card and never needs a click just to show the current state."""
|
||||||
|
from services.database import get_instruments_watchlist, save_wavelet_decomposition_cache
|
||||||
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
|
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
|
||||||
|
|
||||||
sig_cfg = _technical_desk_wavelet_config()
|
sig_cfg = _technical_desk_wavelet_config()
|
||||||
@@ -267,11 +297,9 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
|||||||
for item in get_instruments_watchlist():
|
for item in get_instruments_watchlist():
|
||||||
ticker = item["ticker"]
|
ticker = item["ticker"]
|
||||||
try:
|
try:
|
||||||
hist = get_historical(ticker, period="1y", interval="1d")
|
values, dates = _fetch_close_series(ticker, item.get("saxo_quote_symbol"))
|
||||||
if len(hist) < lookback + 32:
|
if len(values) < lookback + 32:
|
||||||
continue
|
continue
|
||||||
values = [h["close"] for h in hist]
|
|
||||||
dates = [h["date"] for h in hist]
|
|
||||||
start_idx = max(lookback, len(values) - 60)
|
start_idx = max(lookback, len(values) - 60)
|
||||||
decomposed = decomposer(
|
decomposed = decomposer(
|
||||||
values, dates,
|
values, dates,
|
||||||
@@ -280,6 +308,7 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
|||||||
)
|
)
|
||||||
if not decomposed["dates"]:
|
if not decomposed["dates"]:
|
||||||
continue
|
continue
|
||||||
|
save_wavelet_decomposition_cache(ticker, run_id, method, wavelet, num_levels, lookback, decomposed)
|
||||||
price_at_signal = decomposed["original"][-1]
|
price_at_signal = decomposed["original"][-1]
|
||||||
bands = decomposed["bands"]
|
bands = decomposed["bands"]
|
||||||
|
|
||||||
@@ -378,6 +407,6 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
|||||||
|
|
||||||
def compute_and_save_wavelet_signals(run_id: str) -> List[Dict]:
|
def compute_and_save_wavelet_signals(run_id: str) -> List[Dict]:
|
||||||
from services.database import save_wavelet_signals
|
from services.database import save_wavelet_signals
|
||||||
results = scan_watchlist_wavelet_signals()
|
results = scan_watchlist_wavelet_signals(run_id)
|
||||||
save_wavelet_signals(run_id, results)
|
save_wavelet_signals(run_id, results)
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { Link } from 'react-router-dom'
|
|||||||
import { ArrowUpRight } from 'lucide-react'
|
import { ArrowUpRight } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
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[]
|
trades: any[]
|
||||||
mode: 'top' | 'worst' | 'recent'
|
mode: 'top' | 'worst' | 'recent'
|
||||||
title: string
|
title: string
|
||||||
linkTo: string
|
linkTo: string
|
||||||
toggle?: ReactNode
|
toggle?: ReactNode
|
||||||
|
tickerLabel?: (ticker: string) => string
|
||||||
}) {
|
}) {
|
||||||
const sorted = [...trades]
|
const sorted = [...trades]
|
||||||
.sort((a, b) => mode === 'recent'
|
.sort((a, b) => mode === 'recent'
|
||||||
@@ -16,7 +17,6 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
|
|||||||
: mode === 'top'
|
: mode === 'top'
|
||||||
? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999)
|
? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999)
|
||||||
: (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999))
|
: (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999))
|
||||||
.slice(0, 5)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={linkTo} className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{sorted.length > 0 ? (
|
{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) => {
|
{sorted.map((t: any, i: number) => {
|
||||||
const pnl: number | null = t.pnl_pct ?? null
|
const pnl: number | null = t.pnl_pct ?? null
|
||||||
const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
|
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]">
|
<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="text-[9px] text-slate-700 font-mono w-3 shrink-0">{i + 1}</span>
|
||||||
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</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>
|
<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>}
|
{entryDate && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{entryDate}</span>}
|
||||||
{pnl !== null ? (
|
{pnl !== null ? (
|
||||||
|
|||||||
@@ -213,6 +213,31 @@ export default function Dashboard() {
|
|||||||
const lastCycle = (cycleStatusData as any)?.last_cycle ?? null
|
const lastCycle = (cycleStatusData as any)?.last_cycle ?? null
|
||||||
const openPositions: any[] = (openPositionsData as any) ?? []
|
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)
|
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
|
||||||
const cyclePatterns = useMemo(() => {
|
const cyclePatterns = useMemo(() => {
|
||||||
const started = lastCycle?.started_at ?? null
|
const started = lastCycle?.started_at ?? null
|
||||||
@@ -1013,7 +1038,7 @@ export default function Dashboard() {
|
|||||||
{/* ── Command Center: Intelligence & Contexte ── */}
|
{/* ── Command Center: Intelligence & Contexte ── */}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<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
|
{/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each
|
||||||
cycle). Single click on the card = Wavelets Simulation overview; double-click a
|
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) => (
|
{signals.slice(0, 5).map((s: any, i: number) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1456,6 +1456,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [waveletCausal, setWaveletCausal] = useState(false)
|
const [waveletCausal, setWaveletCausal] = useState(false)
|
||||||
const [waveletLookback, setWaveletLookback] = useState(260)
|
const [waveletLookback, setWaveletLookback] = useState(260)
|
||||||
const [waveletData, setWaveletData] = useState<any | null>(null)
|
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 [loadingWavelet, setLoadingWavelet] = useState(false)
|
||||||
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
|
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
|
||||||
const [loadingReliability, setLoadingReliability] = useState(false)
|
const [loadingReliability, setLoadingReliability] = useState(false)
|
||||||
@@ -1655,6 +1661,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
||||||
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
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 () => {
|
const runWaveletAnalysis = async () => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setLoadingWavelet(true)
|
setLoadingWavelet(true)
|
||||||
@@ -1665,15 +1675,47 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
if (waveletCausal) params.lookback = waveletLookback
|
if (waveletCausal) params.lookback = waveletLookback
|
||||||
const { data } = await api.get(path, { params })
|
const { data } = await api.get(path, { params })
|
||||||
setWaveletData(data)
|
setWaveletData(data)
|
||||||
|
setWaveletSource('live')
|
||||||
|
setWaveletCachedAt(null)
|
||||||
setHiddenBands(new Set())
|
setHiddenBands(new Set())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Wavelet analysis failed', e)
|
console.error('Wavelet analysis failed', e)
|
||||||
setWaveletData(null)
|
setWaveletData(null)
|
||||||
|
setWaveletSource(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingWavelet(false)
|
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 () => {
|
const runWaveletReliability = async () => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setLoadingReliability(true)
|
setLoadingReliability(true)
|
||||||
@@ -2080,7 +2122,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{([
|
{([
|
||||||
{ key: 'counters', label: 'Compteurs' },
|
{ key: 'counters', label: 'Compteurs' },
|
||||||
{ key: 'analyse', label: 'Analyse de la courbe' },
|
{ key: 'analyse', label: 'Analyse de la courbe' },
|
||||||
{ key: 'wavelets', label: 'Ondelettes' },
|
{ key: 'wavelets', label: 'Wavelet' },
|
||||||
] as const).map(t => (
|
] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
@@ -2217,7 +2259,19 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{tabUnder === 'wavelets' && (
|
{tabUnder === 'wavelets' && (
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
<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">
|
<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">
|
<div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
|
||||||
<label className="flex items-center gap-1 text-slate-400">
|
<label className="flex items-center gap-1 text-slate-400">
|
||||||
Niveaux
|
Niveaux
|
||||||
@@ -2249,8 +2303,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
|
<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">
|
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>
|
||||||
<button onClick={runWaveletReliability} disabled={loadingReliability}
|
<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."
|
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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user