feat: instrument analysis

This commit is contained in:
OpenSquared
2026-08-01 07:57:44 +02:00
parent 3fb61f1690
commit 7e640a09d0
3 changed files with 81 additions and 45 deletions

View File

@@ -11,7 +11,7 @@ import numpy as np
import pandas as pd
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime, date
from datetime import datetime, date, timedelta
def _base_ticker(t: str) -> str:
@@ -174,7 +174,8 @@ def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str])
"""Persist the Saxo quote-symbol link to the SQLite instrument_overrides table (NOT
instruments.json — that file is baked into the Docker image and gets reset to its
git-tracked contents on every deploy rebuild, which is why this link kept disappearing)
and refresh in-memory config. saxo_symbol=None clears the link (falls back to yfinance)."""
and refresh in-memory config. saxo_symbol=None clears the link — the instrument's chart
then shows a "not linked" empty state (see _fetch_ohlcv), not a yfinance fallback."""
global _configs
if _configs is None:
_load_configs()
@@ -815,43 +816,54 @@ _PERIOD_TO_DAYS = {
}
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str, Optional[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, error) — error is the Saxo failure reason, kept
even when the yfinance fallback also comes up empty (a quick-added instrument's
yf_ticker is just its Cockpit ticker, e.g. "BRENT" — that's never a real yfinance
symbol, so surfacing *why Saxo failed* is the only actionable diagnostic in that case,
rather than a bare empty chart with no explanation)."""
saxo_symbol = config.get("saxo_quote_symbol")
saxo_error = None
if saxo_symbol:
from services.database import get_saxo_catalog_by_symbol
entry = get_saxo_catalog_by_symbol(saxo_symbol)
asset_type = entry["asset_type"] if entry else "FxSpot"
try:
from services.saxo_client import get_price_history
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", None
except Exception as e:
catalog_note = " — pas dans le catalogue Saxo local, asset_type par défaut" if not entry else ""
saxo_error = f"Saxo ({saxo_symbol}, asset_type={asset_type}{catalog_note}): {e}"
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}, asset_type={asset_type}): {e}")
def _indicator_lookback_days(config: Dict[str, Any]) -> int:
"""Extra calendar-day buffer to fetch (and later trim off the visible chart/price_data,
see get_snapshot) purely to warm up rolling-window indicators — a bare "1mo" fetch is
~22 trading days, but MA20/BB(20)/20d-volatility each need 20 PRIOR closes before their
first valid point, leaving almost nothing to draw across the visible window. Sized to
the longest configured window (MA200 by default) so every indicator gets a real chance
to warm up, not just MA20 — same "fetch wider, trim narrower" pattern as
routers/wavelet.py's _fetch_padded_history."""
chart_cfg = config.get("chart", {})
ma_periods = chart_cfg.get("ma_periods", [20, 50, 200])
bb_period = chart_cfg.get("bollinger_period", 20)
vol_window = chart_cfg.get("volatility_window", 20)
longest = max([*ma_periods, bb_period, vol_window, 14]) # 14 covers RSI14/ATR14
return int(longest * 1.6) + 15 # trading-day count -> calendar-day buffer + margin
yf_ticker = config.get("yf_ticker", instrument_id)
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str, pad_days: int = 0) -> Tuple[List[Dict], str, Optional[str]]:
"""Fetch OHLCV records for the snapshot — Saxo ONLY, no yfinance fallback. Instrument
Analysis is Saxo-exclusive by design: a wrong/unlinked instrument shows a clear "not
linked" error (rendered as-is by the frontend's empty-chart state) rather than silently
substituting a different, less trustworthy data source the user has no way to notice
they're looking at. Returns (records, source, error). `pad_days` extends the fetch
further into the past than `period` alone would (see _indicator_lookback_days) — the
caller trims the OUTPUT back to `period` once indicators are computed on the padded set."""
saxo_symbol = config.get("saxo_quote_symbol")
if not saxo_symbol:
return [], "none", (
f"'{instrument_id}' n'est pas lié à un symbole Saxo — Instrument Analysis n'utilise "
"plus yfinance. Lie-le à un symbole Saxo (bouton \"Quote\" dans Config ou dans le "
"sélecteur d'instrument ci-dessus)."
)
from services.database import get_saxo_catalog_by_symbol
entry = get_saxo_catalog_by_symbol(saxo_symbol)
asset_type = entry["asset_type"] if entry else "FxSpot"
try:
from services.data_fetcher import get_historical
records = get_historical(yf_ticker, period=period, interval=interval)
from services.saxo_client import get_price_history
days = _PERIOD_TO_DAYS.get(period, 365) + pad_days
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", None
except Exception as e:
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
records = []
if not records and saxo_error:
return records, "yfinance", saxo_error
return records, "yfinance", None
catalog_note = " — pas dans le catalogue Saxo local, asset_type par défaut" if not entry else ""
error = f"Saxo ({saxo_symbol}, asset_type={asset_type}{catalog_note}): {e}"
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}, asset_type={asset_type}): {e}")
return [], "saxo", error
async def get_snapshot(
@@ -867,11 +879,25 @@ async def get_snapshot(
if not config:
return {"error": f"Unknown instrument: {instrument_id}"}
records, source, source_error = _fetch_ohlcv(config, instrument_id, period, interval)
df = _ohlcv_to_df(records)
pad_days = _indicator_lookback_days(config)
records, source, source_error = _fetch_ohlcv(config, instrument_id, period, interval, pad_days=pad_days)
df_full = _ohlcv_to_df(records)
# Compute everything
indicators = _compute_indicators(df, config) if not df.empty else {}
# Trim back to the originally requested window for everything except indicators — the
# extra lookback above exists purely to warm up rolling-window indicators (MA/BB/
# volatility/RSI/ATR), not to silently widen the chart or shift regime/trend detection
# to a longer history than the user actually selected.
cutoff = (date.today() - timedelta(days=_PERIOD_TO_DAYS.get(period, 365))).isoformat()
visible_records = [r for r in records if str(r.get("date", ""))[:10] >= cutoff]
df = _ohlcv_to_df(visible_records)
# Compute everything — indicators over the padded df_full (so MA20/BB(20)/volatility
# etc. have real prior closes to roll over from the very first visible day, instead of
# only producing a value once ~20 trading days have accumulated INSIDE a short period
# like "1mo"), then trimmed back to the visible window so the chart's indicator lines
# don't extend further back than the candles themselves.
indicators = _compute_indicators(df_full, config) if not df_full.empty else {}
indicators = {k: [pt for pt in v if pt["time"] >= cutoff] for k, v in indicators.items()}
regime = _detect_regime(df, config) if not df.empty else {}
trend = _get_trend_summary(df) if not df.empty else {}
@@ -890,8 +916,8 @@ async def get_snapshot(
# Price data for chart (time + ohlcv)
price_data = []
if records:
for r in records:
if visible_records:
for r in visible_records:
price_data.append({
"time": str(r.get("date", ""))[:10],
"open": r.get("open"),