feat: instrument analysis
This commit is contained in:
@@ -11,7 +11,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
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:
|
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
|
"""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
|
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)
|
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
|
global _configs
|
||||||
if _configs is None:
|
if _configs is None:
|
||||||
_load_configs()
|
_load_configs()
|
||||||
@@ -815,23 +816,44 @@ _PERIOD_TO_DAYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str, Optional[str]]:
|
def _indicator_lookback_days(config: Dict[str, Any]) -> int:
|
||||||
"""Fetch OHLCV records for the snapshot — Saxo-first when the instrument has a
|
"""Extra calendar-day buffer to fetch (and later trim off the visible chart/price_data,
|
||||||
saxo_quote_symbol linked, yfinance otherwise (or as a silent fallback on any Saxo
|
see get_snapshot) purely to warm up rolling-window indicators — a bare "1mo" fetch is
|
||||||
failure). Returns (records, source, error) — error is the Saxo failure reason, kept
|
~22 trading days, but MA20/BB(20)/20d-volatility each need 20 PRIOR closes before their
|
||||||
even when the yfinance fallback also comes up empty (a quick-added instrument's
|
first valid point, leaving almost nothing to draw across the visible window. Sized to
|
||||||
yf_ticker is just its Cockpit ticker, e.g. "BRENT" — that's never a real yfinance
|
the longest configured window (MA200 by default) so every indicator gets a real chance
|
||||||
symbol, so surfacing *why Saxo failed* is the only actionable diagnostic in that case,
|
to warm up, not just MA20 — same "fetch wider, trim narrower" pattern as
|
||||||
rather than a bare empty chart with no explanation)."""
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
saxo_symbol = config.get("saxo_quote_symbol")
|
||||||
saxo_error = None
|
if not saxo_symbol:
|
||||||
if 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
|
from services.database import get_saxo_catalog_by_symbol
|
||||||
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
||||||
asset_type = entry["asset_type"] if entry else "FxSpot"
|
asset_type = entry["asset_type"] if entry else "FxSpot"
|
||||||
try:
|
try:
|
||||||
from services.saxo_client import get_price_history
|
from services.saxo_client import get_price_history
|
||||||
days = _PERIOD_TO_DAYS.get(period, 365)
|
days = _PERIOD_TO_DAYS.get(period, 365) + pad_days
|
||||||
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
||||||
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
||||||
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
||||||
@@ -839,19 +861,9 @@ def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interv
|
|||||||
return records, "saxo", None
|
return records, "saxo", None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
catalog_note = " — pas dans le catalogue Saxo local, asset_type par défaut" if not entry else ""
|
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}"
|
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}")
|
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}, asset_type={asset_type}): {e}")
|
||||||
|
return [], "saxo", error
|
||||||
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 = []
|
|
||||||
if not records and saxo_error:
|
|
||||||
return records, "yfinance", saxo_error
|
|
||||||
return records, "yfinance", None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_snapshot(
|
async def get_snapshot(
|
||||||
@@ -867,11 +879,25 @@ async def get_snapshot(
|
|||||||
if not config:
|
if not config:
|
||||||
return {"error": f"Unknown instrument: {instrument_id}"}
|
return {"error": f"Unknown instrument: {instrument_id}"}
|
||||||
|
|
||||||
records, source, source_error = _fetch_ohlcv(config, instrument_id, period, interval)
|
pad_days = _indicator_lookback_days(config)
|
||||||
df = _ohlcv_to_df(records)
|
records, source, source_error = _fetch_ohlcv(config, instrument_id, period, interval, pad_days=pad_days)
|
||||||
|
df_full = _ohlcv_to_df(records)
|
||||||
|
|
||||||
# Compute everything
|
# Trim back to the originally requested window for everything except indicators — the
|
||||||
indicators = _compute_indicators(df, config) if not df.empty else {}
|
# 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 {}
|
regime = _detect_regime(df, config) if not df.empty else {}
|
||||||
trend = _get_trend_summary(df) 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 for chart (time + ohlcv)
|
||||||
price_data = []
|
price_data = []
|
||||||
if records:
|
if visible_records:
|
||||||
for r in records:
|
for r in visible_records:
|
||||||
price_data.append({
|
price_data.append({
|
||||||
"time": str(r.get("date", ""))[:10],
|
"time": str(r.get("date", ""))[:10],
|
||||||
"open": r.get("open"),
|
"open": r.get("open"),
|
||||||
|
|||||||
@@ -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
|
// 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_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.
|
// 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
|
ticker: string
|
||||||
kind: SaxoLinkKind
|
kind: SaxoLinkKind
|
||||||
saxoSymbol: string | null
|
saxoSymbol: string | null
|
||||||
onSave: (symbol: string | null) => void
|
onSave: (symbol: string | null) => void
|
||||||
isPending: boolean
|
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 [editing, setEditing] = useState(false)
|
||||||
const [value, setValue] = useState(saxoSymbol ?? '')
|
const [value, setValue] = useState(saxoSymbol ?? '')
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const meta = SAXO_LINK_KIND_META[kind]
|
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 save = (sym?: string) => {
|
||||||
const resolved = (sym ?? value).trim().toUpperCase() || null
|
const resolved = (sym ?? value).trim().toUpperCase() || null
|
||||||
|
|||||||
@@ -2045,7 +2045,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
|
<SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
|
||||||
isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} />
|
isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave}
|
||||||
|
assetTypes={selected.category === 'fx' ? 'FxSpot' : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayPrice !== undefined && (
|
{displayPrice !== undefined && (
|
||||||
|
|||||||
Reference in New Issue
Block a user