feat: wavelets
This commit is contained in:
@@ -81,13 +81,13 @@ def wavelet_cache(instrument_id: str) -> Dict[str, Any]:
|
||||
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
|
||||
Looked up by the Cockpit Watchlist ticker the cache is keyed on, resolved via
|
||||
instrument_service.resolve_watchlist_ticker — handles both the yfinance-suffix case
|
||||
(EURUSD=X -> EURUSD) and the alias case where the catalog id and the Watchlist ticker
|
||||
share no common substring at all (GC=F -> GOLD, ^GSPC -> SP500)."""
|
||||
from services.instrument_service import resolve_watchlist_ticker
|
||||
from services.database import get_wavelet_decomposition_cache
|
||||
cached = get_wavelet_decomposition_cache(_base_ticker(instrument_id))
|
||||
cached = get_wavelet_decomposition_cache(resolve_watchlist_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 {
|
||||
|
||||
@@ -3623,32 +3623,42 @@ def get_latest_wavelet_signals() -> List[Dict]:
|
||||
|
||||
|
||||
def get_latest_wavelet_state_by_instrument() -> List[Dict]:
|
||||
"""One row per Watchlist instrument from the most recent scan — a fired signal if this
|
||||
run had one for it (any band), otherwise the fastest band's current slope/direction, so
|
||||
every scanned instrument shows *something* here instead of only the ones with an active
|
||||
trigger this run. get_latest_wavelet_signals() (fired-only) stays as-is for any other
|
||||
caller; this is specifically for the Dashboard's Wavelets Signal card, which should
|
||||
reflect the latest state per instrument, not just a list of firings."""
|
||||
run_id = _latest_wavelet_run_id()
|
||||
if not run_id:
|
||||
return []
|
||||
"""One row per Watchlist instrument, always — its own most recently scanned band state
|
||||
(a fired signal if that scan had one, otherwise the fastest band's slope/direction), or
|
||||
a data=None placeholder if it's never been scanned successfully at all. Looked up
|
||||
independently per ticker (its own MAX(computed_at)) rather than pinned to one shared
|
||||
run_id, so an instrument that failed the latest scan (e.g. a transient Saxo hiccup)
|
||||
still shows its last known state instead of silently disappearing from the list, and
|
||||
self-heals the next time it scans successfully. Feeds the Dashboard's Wavelets Signal
|
||||
card, which should mirror the Watchlist exactly — every instrument represented, not
|
||||
just the ones with fresh data this run."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND band_label != 'ridge' "
|
||||
"ORDER BY ticker, (signal_kind IS NULL), period_low_days ASC",
|
||||
(run_id,),
|
||||
"SELECT w.* FROM wavelet_watchlist_signals w "
|
||||
"JOIN (SELECT ticker, MAX(computed_at) AS max_computed_at FROM wavelet_watchlist_signals "
|
||||
" WHERE band_label != 'ridge' GROUP BY ticker) w2 "
|
||||
" ON w.ticker = w2.ticker AND w.computed_at = w2.max_computed_at "
|
||||
"WHERE w.band_label != 'ridge' "
|
||||
"ORDER BY w.ticker, (w.signal_kind IS NULL), w.period_low_days ASC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
by_ticker: Dict[str, Dict] = {}
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
by_ticker.setdefault(d["ticker"], d)
|
||||
out = list(by_ticker.values())
|
||||
# No fired signal this run for this band -> direction is null; fall back to the band's
|
||||
# own slope sign so the card can still show an up/down arrow instead of a blank one.
|
||||
for d in out:
|
||||
# No fired signal for this band -> direction is null; fall back to the band's own slope
|
||||
# sign so the card can still show an up/down arrow instead of a blank one.
|
||||
for d in by_ticker.values():
|
||||
if d.get("direction") is None and d.get("slope") is not None:
|
||||
d["direction"] = "up" if d["slope"] > 0 else "down"
|
||||
|
||||
out: List[Dict] = []
|
||||
for item in get_instruments_watchlist():
|
||||
ticker = item["ticker"].upper()
|
||||
out.append(by_ticker.get(ticker) or {
|
||||
"ticker": ticker, "band_label": None, "signal_kind": None, "direction": None,
|
||||
"computed_at": None, "slope": None, "value": None, "energy": None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,45 @@ def _base_ticker(t: str) -> str:
|
||||
"""Normalize Yahoo Finance tickers for comparison: EURUSD=X → EURUSD, BZ=F → BZ, ^GSPC → GSPC."""
|
||||
return re.sub(r'(=X|=F|=RR|-USD|\^)$', '', t.strip().upper())
|
||||
|
||||
|
||||
# Reverse of the common "yfinance/futures ticker" an Instrument Analysis id (curated or
|
||||
# quick-added) uses vs. the Cockpit Watchlist's own friendly ticker for the same underlying
|
||||
# — needed because _base_ticker() only strips a SUFFIX (EURUSD=X -> EURUSD works since the
|
||||
# base "EURUSD" already equals the Watchlist ticker), it can't turn "GC=F" into "GOLD" or
|
||||
# "^GSPC" into "SP500" (nothing in common to strip; ^ is a prefix, not a suffix, so
|
||||
# _base_ticker doesn't even touch it despite what its own docstring claims). Mirrors
|
||||
# frontend/src/pages/Dashboard.tsx's UNDERLYING_ALIASES table (kept in sync manually — a
|
||||
# small, stable list, not worth sharing across a Python/TS boundary).
|
||||
_WAVELET_UNDERLYING_ALIASES = {
|
||||
"^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",
|
||||
}
|
||||
|
||||
|
||||
def resolve_watchlist_ticker(instrument_id: str) -> str:
|
||||
"""Best-effort map from an Instrument Analysis id to the Cockpit Watchlist ticker it
|
||||
represents — for looking up data keyed by the Watchlist ticker (wavelet cache in
|
||||
particular). Tries an exact match, then the suffix-stripped form (EURUSD=X -> EURUSD),
|
||||
then the alias table above (GC=F -> GOLD) — each checked against the tickers actually
|
||||
in the Watchlist right now, not just "is this a known alias", so a curated catalog id
|
||||
that happens to share a root with an alias but isn't Watchlist-linked doesn't falsely
|
||||
resolve. Falls back to the suffix-stripped form if nothing matches."""
|
||||
uid = instrument_id.strip().upper()
|
||||
base = _base_ticker(uid)
|
||||
from services.database import get_instruments_watchlist
|
||||
watchlist_tickers = {r["ticker"].upper() for r in get_instruments_watchlist()}
|
||||
if uid in watchlist_tickers:
|
||||
return uid
|
||||
if base in watchlist_tickers:
|
||||
return base
|
||||
alias = _WAVELET_UNDERLYING_ALIASES.get(uid) or _WAVELET_UNDERLYING_ALIASES.get(base)
|
||||
if alias and alias in watchlist_tickers:
|
||||
return alias
|
||||
return base
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Config loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,6 +25,18 @@ from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Watchlist ticker -> a real yfinance symbol, for the fallback fetch when this instrument
|
||||
# has no saxo_quote_symbol link (or its Saxo fetch fails) — mirrors
|
||||
# instrument_service.py's _WAVELET_UNDERLYING_ALIASES (reverse direction: that one maps a
|
||||
# yfinance/futures id back to the Watchlist ticker for cache lookups, this one maps the
|
||||
# Watchlist ticker forward to a fetchable yfinance symbol).
|
||||
_FRIENDLY_TO_YFINANCE = {
|
||||
"GOLD": "GC=F", "SILVER": "SI=F", "COPPER": "HG=F", "PLATINUM": "PL=F",
|
||||
"CRUDE": "CL=F", "BRENT": "BZ=F", "NATGAS": "NG=F",
|
||||
"WHEAT": "ZW=F", "CORN": "ZC=F", "SOYBEANS": "ZS=F",
|
||||
"SP500": "^GSPC", "NASDAQ": "^NDX", "DOW": "^DJI", "RUSSELL2000": "^RUT",
|
||||
}
|
||||
|
||||
|
||||
def _compute_slope(series: List[float]) -> List[float]:
|
||||
n = len(series)
|
||||
@@ -256,12 +268,15 @@ def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]):
|
||||
|
||||
from services.data_fetcher import get_historical
|
||||
yf_ticker = ticker.upper()
|
||||
# Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker convention
|
||||
# here but not a real yfinance symbol (needs the "=X" suffix) — without this, any
|
||||
# Saxo-linked FX pair whose Saxo fetch fails falls through to a yfinance call that's
|
||||
# guaranteed to return nothing, permanently keeping it out of the wavelet cache no
|
||||
# matter how many refreshes run.
|
||||
if len(yf_ticker) == 6 and yf_ticker.isalpha():
|
||||
if yf_ticker in _FRIENDLY_TO_YFINANCE:
|
||||
# GOLD/CRUDE/BRENT/SP500... aren't real yfinance symbols either — without this, any
|
||||
# Saxo-linked commodity/index whose Saxo fetch fails falls through to a yfinance call
|
||||
# that's guaranteed to return nothing, permanently keeping it out of the wavelet
|
||||
# cache no matter how many refreshes run.
|
||||
yf_ticker = _FRIENDLY_TO_YFINANCE[yf_ticker]
|
||||
elif len(yf_ticker) == 6 and yf_ticker.isalpha():
|
||||
# Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker
|
||||
# convention here but not a real yfinance symbol (needs the "=X" suffix).
|
||||
yf_ticker += "=X"
|
||||
hist = get_historical(yf_ticker, period="1y", interval="1d")
|
||||
return [h["close"] for h in hist], [h["date"] for h in hist]
|
||||
|
||||
Reference in New Issue
Block a user