feat: instrument analysis
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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).")
|
||||
|
||||
|
||||
@@ -75,6 +75,32 @@ def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None:
|
||||
logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)")
|
||||
|
||||
|
||||
def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) -> None:
|
||||
"""Persist the Saxo quote-symbol link to instruments.json and refresh in-memory config.
|
||||
saxo_symbol=None clears the link (falls back to yfinance)."""
|
||||
global _configs
|
||||
if _configs is None:
|
||||
_load_configs()
|
||||
|
||||
uid = instrument_id.upper()
|
||||
if uid not in _configs:
|
||||
raise ValueError(f"Instrument {uid} not found")
|
||||
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
for inst in raw["instruments"]:
|
||||
if inst["id"] == uid:
|
||||
inst["saxo_quote_symbol"] = saxo_symbol
|
||||
break
|
||||
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(raw, f, ensure_ascii=False, indent=2)
|
||||
|
||||
_configs[uid]["saxo_quote_symbol"] = saxo_symbol
|
||||
logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}")
|
||||
|
||||
|
||||
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
||||
@@ -653,6 +679,46 @@ def _get_relevant_events(
|
||||
|
||||
# ── Main snapshot ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Approximate calendar-day span of each yfinance-style period string — used to size the
|
||||
# Saxo `days` fetch window (Saxo's Chart API is day-count based, not period-string based).
|
||||
# Same values as routers/wavelet.py's _PERIOD_TO_DAYS; duplicated locally since it's a
|
||||
# tiny, stable lookup table not worth sharing across modules.
|
||||
_PERIOD_TO_DAYS = {
|
||||
"5d": 5, "1mo": 30, "3mo": 90, "6mo": 182,
|
||||
"1y": 365, "2y": 730, "5y": 1825, "10y": 3650, "max": 3650,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str]:
|
||||
"""Fetch OHLCV records for the snapshot — Saxo-first when the instrument has a
|
||||
saxo_quote_symbol linked, yfinance otherwise (or as a silent fallback on any Saxo
|
||||
failure). Returns (records, source)."""
|
||||
saxo_symbol = config.get("saxo_quote_symbol")
|
||||
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"
|
||||
days = _PERIOD_TO_DAYS.get(period, 365)
|
||||
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
||||
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
||||
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
||||
for b in bars]
|
||||
return records, "saxo"
|
||||
except Exception as e:
|
||||
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}), falling back to yfinance: {e}")
|
||||
|
||||
yf_ticker = config.get("yf_ticker", instrument_id)
|
||||
try:
|
||||
from services.data_fetcher import get_historical
|
||||
records = get_historical(yf_ticker, period=period, interval=interval)
|
||||
except Exception as e:
|
||||
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
||||
records = []
|
||||
return records, "yfinance"
|
||||
|
||||
|
||||
async def get_snapshot(
|
||||
instrument_id: str,
|
||||
period: str = "1y",
|
||||
@@ -666,16 +732,7 @@ async def get_snapshot(
|
||||
if not config:
|
||||
return {"error": f"Unknown instrument: {instrument_id}"}
|
||||
|
||||
yf_ticker = config.get("yf_ticker", instrument_id)
|
||||
|
||||
# Fetch OHLCV
|
||||
try:
|
||||
from services.data_fetcher import get_historical
|
||||
records = get_historical(yf_ticker, period=period, interval=interval)
|
||||
except Exception as e:
|
||||
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
||||
records = []
|
||||
|
||||
records, source = _fetch_ohlcv(config, instrument_id, period, interval)
|
||||
df = _ohlcv_to_df(records)
|
||||
|
||||
# Compute everything
|
||||
@@ -752,6 +809,7 @@ async def get_snapshot(
|
||||
"change_pct": change_pct,
|
||||
"change_abs": change_abs,
|
||||
"period": period,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -249,7 +249,9 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
|
||||
Callers should treat any failure here (entitlement gap, still-wrong field names for
|
||||
some other asset type, etc.) as routine and fall back to another source, not surface
|
||||
it as a hard error.
|
||||
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float}, ...].
|
||||
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float, "open": float|None,
|
||||
"high": float|None, "low": float|None, "volume": float|None}, ...] — open/high/low/volume
|
||||
are None when the raw bar doesn't carry them (e.g. FX Spot has no traded volume).
|
||||
"""
|
||||
instrument = resolve_instrument(symbol, asset_types=asset_type)
|
||||
data = _get("/chart/v3/charts", {
|
||||
@@ -271,7 +273,14 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
|
||||
close = (bar["CloseBid"] + bar["CloseAsk"]) / 2
|
||||
if close is None or not time_str:
|
||||
continue
|
||||
out.append({"date": str(time_str)[:10], "close": float(close)})
|
||||
out.append({
|
||||
"date": str(time_str)[:10],
|
||||
"close": float(close),
|
||||
"open": float(bar["Open"]) if bar.get("Open") is not None else None,
|
||||
"high": float(bar["High"]) if bar.get("High") is not None else None,
|
||||
"low": float(bar["Low"]) if bar.get("Low") is not None else None,
|
||||
"volume": float(bar["Volume"]) if bar.get("Volume") is not None else None,
|
||||
})
|
||||
if not out:
|
||||
raise ValueError(f"Chart data for '{symbol}' had no usable Close/CloseMid/CloseBid+CloseAsk/Time fields — raw bar keys: {list(bars[0].keys()) if bars else []}")
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user