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

@@ -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,
}

View File

@@ -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