feat: instrument analysis
This commit is contained in:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user