From 7e640a09d0269767d712cf24f8a41fe4761310e2 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 1 Aug 2026 07:57:44 +0200 Subject: [PATCH] feat: instrument analysis --- backend/services/instrument_service.py | 110 +++++++++++++-------- frontend/src/components/SaxoLinkPicker.tsx | 12 ++- frontend/src/pages/InstrumentDashboard.tsx | 4 +- 3 files changed, 81 insertions(+), 45 deletions(-) diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index 2130b24..1494856 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -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"), diff --git a/frontend/src/components/SaxoLinkPicker.tsx b/frontend/src/components/SaxoLinkPicker.tsx index 3cb59d1..19bf852 100644 --- a/frontend/src/components/SaxoLinkPicker.tsx +++ b/frontend/src/components/SaxoLinkPicker.tsx @@ -41,18 +41,26 @@ export type SaxoLinkKind = keyof typeof SAXO_LINK_KIND_META // injected by the caller so this can drive any backing store (instruments_watchlist's // saxo_option_symbol/saxo_quote_symbol columns via Config.tsx, or instruments.json's // saxo_quote_symbol field via InstrumentDashboard.tsx) without knowing which one it is. -export default function SaxoLinkPicker({ ticker, kind, saxoSymbol, onSave, isPending }: { +export default function SaxoLinkPicker({ ticker, kind, saxoSymbol, onSave, isPending, assetTypes }: { ticker: string kind: SaxoLinkKind saxoSymbol: string | null onSave: (symbol: string | null) => void isPending: boolean + // Overrides SAXO_LINK_KIND_META[kind]'s default asset types — e.g. an FX instrument's + // "Quote" link should only ever search FxSpot, not the default's ContractFutures/ + // CfdOnFutures/StockIndex too (those exist for commodities/indices priced via futures on + // Saxo, but searching "EURUSD" under the unfiltered default surfaces CME EURUSD futures + // contracts alongside the actual spot rate — a different product, not what a currency + // pair's price should ever be linked to). + assetTypes?: string }) { const [editing, setEditing] = useState(false) const [value, setValue] = useState(saxoSymbol ?? '') const [showDropdown, setShowDropdown] = useState(false) const meta = SAXO_LINK_KIND_META[kind] - const { data: catalogMatches } = useSaxoCatalog(meta.assetTypes, value.length >= 2 ? value : undefined) + const effectiveAssetTypes = assetTypes ?? meta.assetTypes + const { data: catalogMatches } = useSaxoCatalog(effectiveAssetTypes, value.length >= 2 ? value : undefined) const save = (sym?: string) => { const resolved = (sym ?? value).trim().toUpperCase() || null diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index fc997ca..b7902e9 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -2045,7 +2045,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {selected && ( + isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} + assetTypes={selected.category === 'fx' ? 'FxSpot' : undefined} + /> )} {displayPrice !== undefined && (