feat: instrument analysis

This commit is contained in:
OpenSquared
2026-07-23 22:28:56 +02:00
parent 548bad2dcd
commit e0c4aa8f65
9 changed files with 2395 additions and 307 deletions

View File

@@ -15,11 +15,16 @@ from services.instrument_service import (
get_snapshot,
get_narrative,
update_instrument_drivers,
update_instrument_saxo_link,
)
class DriverUpdate(BaseModel):
drivers: List[Dict[str, Any]]
class SaxoLinkBody(BaseModel):
saxo_symbol: Optional[str] = None
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
@@ -90,6 +95,25 @@ def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
@router.put("/{instrument_id}/saxo-link")
def set_saxo_link(instrument_id: str, body: SaxoLinkBody) -> Dict[str, Any]:
"""Link this instrument to the Saxo symbol used to price it (chart, indicators, regime,
wavelets) instead of yfinance — or pass null to unlink. Mirrors
routers/instruments_watchlist.py's /{ticker}/saxo-quote-link, but persisted in
instruments.json (this catalog's own store) rather than the instruments_watchlist table."""
config = get_instrument(instrument_id)
if not config:
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
saxo_symbol = (body.saxo_symbol or "").strip().upper() or None
try:
update_instrument_saxo_link(instrument_id, saxo_symbol)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"instrument_id": instrument_id.upper(), "saxo_quote_symbol": saxo_symbol}
# ── Instrument mult (pips → price conversion) ─────────────────────────────────
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}

View File

@@ -23,7 +23,26 @@ _PERIOD_TO_DAYS = {
}
def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None):
def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None, saxo_symbol: Optional[str] = None):
"""Saxo-first when saxo_symbol is given (resolved via the watchlist/Instrument Analysis
Saxo link), yfinance otherwise or as a silent fallback on any Saxo failure. Wavelets only
ever consume the close series, so unlike instrument_service's snapshot fetch this doesn't
need full OHLCV."""
if saxo_symbol and start is None and end is None:
# Saxo's Chart API is day-count-from-now only (no explicit date range), so a custom
# start_date/end_date request can't be served from Saxo — falls through to yfinance.
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"
days = _PERIOD_TO_DAYS.get(period, 365)
bars = get_price_history(saxo_symbol, asset_type, days=days)
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] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}")
from services.data_fetcher import get_historical
hist = get_historical(symbol, period=period, interval=interval, start=start, end=end)
values = [h["close"] for h in hist]
@@ -34,7 +53,7 @@ def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Option
def _fetch_padded_history(
symbol: str, lookback: int, period: str = "1y",
start_date: Optional[str] = None, end_date: Optional[str] = None,
future_padding_days: int = 0,
future_padding_days: int = 0, saxo_symbol: Optional[str] = None,
):
"""Fetch enough history to cover the requested causal-output range plus a
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).
@@ -60,7 +79,7 @@ def _fetch_padded_history(
if start_date:
fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat()
fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end)
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end, saxo_symbol=saxo_symbol)
return values, dates, start_date
out_days = _PERIOD_TO_DAYS.get(period, 365)
@@ -69,7 +88,7 @@ def _fetch_padded_history(
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
"10y",
)
values, dates = _fetch_history(symbol, fetch_period)
values, dates = _fetch_history(symbol, fetch_period, saxo_symbol=saxo_symbol)
cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
return values, dates, cutoff
@@ -84,10 +103,11 @@ def wavelet_analyze(
method: str = Query("cwt", description="cwt (default) or ssq"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
):
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date)
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date, saxo_symbol=saxo_symbol)
if len(values) < 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
@@ -112,13 +132,14 @@ def wavelet_rolling(
method: str = Query("cwt", description="cwt (default) or ssq"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
):
"""Walk-forward version of /analyze: band values are computed day by day from a
trailing `lookback`-point window only, so a trade simulation built on this never
sees data past its own decision date."""
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date)
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, saxo_symbol=saxo_symbol)
if len(values) < lookback + 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
@@ -158,6 +179,7 @@ def wavelet_reliability_endpoint(
max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
):
"""For every reversal a live (causal, walk-forward) decomposition would have flagged,
checks whether redoing the decomposition later still shows the same reversal — a
@@ -169,7 +191,7 @@ def wavelet_reliability_endpoint(
# The horizon is now computed per band inside wavelet_reliability (from each band's own
# measured cycle length), so we don't know it in advance here — pad generously enough to
# cover even a slow band's cycle instead.
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding)
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding, saxo_symbol=saxo_symbol)
if len(values) < lookback + max_future_padding + 32:
raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).")