feat: cockpit
This commit is contained in:
@@ -223,6 +223,22 @@ def init_db():
|
||||
price_at_signal REAL
|
||||
)""",
|
||||
"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
|
||||
"""CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
||||
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]
|
||||
|
||||
|
||||
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 ────────────────────────────────────
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
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
|
||||
instrument. Every (ticker, band) gets a row every cycle — current slope/
|
||||
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 ->
|
||||
acceleration -> band_cross -> energy_threshold). ridge_shift is evaluated
|
||||
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."""
|
||||
from services.database import get_instruments_watchlist
|
||||
from services.data_fetcher import get_historical
|
||||
decomposition) and stored as an extra band_label="ridge" row.
|
||||
|
||||
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
|
||||
|
||||
sig_cfg = _technical_desk_wavelet_config()
|
||||
@@ -267,11 +297,9 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
||||
for item in get_instruments_watchlist():
|
||||
ticker = item["ticker"]
|
||||
try:
|
||||
hist = get_historical(ticker, period="1y", interval="1d")
|
||||
if len(hist) < lookback + 32:
|
||||
values, dates = _fetch_close_series(ticker, item.get("saxo_quote_symbol"))
|
||||
if len(values) < lookback + 32:
|
||||
continue
|
||||
values = [h["close"] for h in hist]
|
||||
dates = [h["date"] for h in hist]
|
||||
start_idx = max(lookback, len(values) - 60)
|
||||
decomposed = decomposer(
|
||||
values, dates,
|
||||
@@ -280,6 +308,7 @@ def scan_watchlist_wavelet_signals() -> List[Dict]:
|
||||
)
|
||||
if not decomposed["dates"]:
|
||||
continue
|
||||
save_wavelet_decomposition_cache(ticker, run_id, method, wavelet, num_levels, lookback, decomposed)
|
||||
price_at_signal = decomposed["original"][-1]
|
||||
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]:
|
||||
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)
|
||||
return results
|
||||
|
||||
Reference in New Issue
Block a user