feat: saxo price
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Intentionally duplicated from market_data.py to keep this system fully decoupled
|
# Intentionally duplicated from market_data.py to keep this system fully decoupled
|
||||||
# from the other instrument-list mechanisms (market_watchlist, INSTRUMENT_MODELS,
|
# from the other instrument-list mechanisms (market_watchlist, INSTRUMENT_MODELS,
|
||||||
@@ -23,19 +26,40 @@ def list_watchlist():
|
|||||||
return get_instruments_watchlist()
|
return get_instruments_watchlist()
|
||||||
|
|
||||||
|
|
||||||
|
def _saxo_quote(saxo_symbol: str) -> Optional[dict]:
|
||||||
|
"""Try Saxo's own chart history for price/change/volatility — avoids the unadjusted-
|
||||||
|
roll artifact continuous futures tickers (BZ=F, CL=F, GC=F...) have on yfinance.
|
||||||
|
Untested against a live account; any failure here is routine, not an error — the
|
||||||
|
caller falls back to yfinance."""
|
||||||
|
from services.database import get_saxo_catalog_by_symbol
|
||||||
|
from services.saxo_client import get_saxo_quote_with_volatility
|
||||||
|
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
||||||
|
asset_type = entry["asset_type"] if entry else "FxSpot"
|
||||||
|
try:
|
||||||
|
return get_saxo_quote_with_volatility(saxo_symbol, asset_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"[watchlist/quotes] Saxo quote failed for '{saxo_symbol}' ({asset_type}), falling back to yfinance: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/quotes")
|
@router.get("/quotes")
|
||||||
def watchlist_quotes():
|
def watchlist_quotes():
|
||||||
from services.database import get_instruments_watchlist
|
from services.database import get_instruments_watchlist
|
||||||
from services.data_fetcher import get_quote_with_volatility
|
from services.data_fetcher import get_quote_with_volatility
|
||||||
items = []
|
items = []
|
||||||
for row in get_instruments_watchlist():
|
for row in get_instruments_watchlist():
|
||||||
q = get_quote_with_volatility(row["ticker"]) or {}
|
q = None
|
||||||
|
if row.get("saxo_symbol"):
|
||||||
|
q = _saxo_quote(row["saxo_symbol"])
|
||||||
|
if q is None:
|
||||||
|
q = get_quote_with_volatility(row["ticker"]) or {}
|
||||||
items.append({
|
items.append({
|
||||||
**row,
|
**row,
|
||||||
"price": q.get("price"),
|
"price": q.get("price"),
|
||||||
"change_pct": q.get("change_pct"),
|
"change_pct": q.get("change_pct"),
|
||||||
"volatility_pct": q.get("volatility_pct"),
|
"volatility_pct": q.get("volatility_pct"),
|
||||||
"volatility_change_pct": q.get("volatility_change_pct"),
|
"volatility_change_pct": q.get("volatility_change_pct"),
|
||||||
|
"quote_source": q.get("source", "yfinance"),
|
||||||
})
|
})
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ quoting window carry a Call/Put payload; the rest are bare {Index, Strike}.
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -224,6 +224,89 @@ def get_price_quote(symbol: str, asset_type: str = "FxSpot", amount: int = 10000
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -> List[Dict[str, Any]]:
|
||||||
|
"""Daily OHLC bars via Saxo's Chart API (GET /chart/v1/charts, Horizon=1440 = daily
|
||||||
|
bars). NOT yet verified against a live account (unlike get_price_quote/
|
||||||
|
snapshot_options_chain, which were built against confirmed real responses) — this
|
||||||
|
follows Saxo's documented chart endpoint shape but needs a live check once deployed.
|
||||||
|
Callers should treat any failure here (wrong field names, entitlement gap, 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}, ...].
|
||||||
|
"""
|
||||||
|
instrument = resolve_instrument(symbol, asset_types=asset_type)
|
||||||
|
data = _get("/chart/v1/charts", {
|
||||||
|
"AssetType": asset_type,
|
||||||
|
"Uic": instrument["uic"],
|
||||||
|
"Horizon": 1440,
|
||||||
|
"Count": days,
|
||||||
|
})
|
||||||
|
bars = data.get("Data") or []
|
||||||
|
if not bars:
|
||||||
|
raise ValueError(f"No chart data returned for '{symbol}' ({asset_type})")
|
||||||
|
out = []
|
||||||
|
for bar in bars:
|
||||||
|
close = bar.get("Close")
|
||||||
|
time_str = bar.get("Time")
|
||||||
|
if close is None or not time_str:
|
||||||
|
continue
|
||||||
|
out.append({"date": str(time_str)[:10], "close": float(close)})
|
||||||
|
if not out:
|
||||||
|
raise ValueError(f"Chart data for '{symbol}' had no usable Close/Time fields")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_saxo_quote_with_volatility(symbol: str, asset_type: str, vol_window: int = 20) -> Dict[str, Any]:
|
||||||
|
"""Saxo-sourced equivalent of services.data_fetcher.get_quote_with_volatility — same
|
||||||
|
output shape, but priced from Saxo's own chart history instead of yfinance. Exists
|
||||||
|
because continuous front-month futures tickers (BZ=F, CL=F, GC=F...) on yfinance
|
||||||
|
aren't roll-adjusted, so day-over-day change/volatility can show a spurious jump on
|
||||||
|
a contract-roll day that has nothing to do with an actual market move. Saxo's own
|
||||||
|
CFD/spot instruments don't have that artifact. Raises on any failure — the caller
|
||||||
|
(routers/instruments_watchlist.py) falls back to yfinance rather than surfacing this."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
bars = get_price_history(symbol, asset_type, days=max(vol_window + 10, 90))
|
||||||
|
if len(bars) < vol_window + 2:
|
||||||
|
raise ValueError(f"Not enough Saxo history for '{symbol}' to compute {vol_window}d volatility")
|
||||||
|
|
||||||
|
dates = [b["date"] for b in bars]
|
||||||
|
closes = np.array([b["close"] for b in bars], dtype=float)
|
||||||
|
|
||||||
|
price = float(closes[-1])
|
||||||
|
last_date = dates[-1]
|
||||||
|
prior_idx = [i for i, d in enumerate(dates) if d < last_date]
|
||||||
|
prev = float(closes[prior_idx[-1]]) if prior_idx else price
|
||||||
|
change = price - prev
|
||||||
|
change_pct = (change / prev * 100) if prev else 0
|
||||||
|
|
||||||
|
log_ret = np.diff(np.log(closes))
|
||||||
|
if len(log_ret) < vol_window:
|
||||||
|
raise ValueError(f"Not enough Saxo return points for '{symbol}' to compute {vol_window}d volatility")
|
||||||
|
vol_series = []
|
||||||
|
for i in range(vol_window, len(log_ret) + 1):
|
||||||
|
window = log_ret[i - vol_window:i]
|
||||||
|
vol_series.append((dates[i], float(np.std(window, ddof=1)) * np.sqrt(252) * 100))
|
||||||
|
if not vol_series:
|
||||||
|
raise ValueError(f"Volatility series empty for '{symbol}'")
|
||||||
|
volatility_pct = vol_series[-1][1]
|
||||||
|
|
||||||
|
volatility_change_pct = None
|
||||||
|
prior_vol = [v for d, v in vol_series if d < last_date]
|
||||||
|
if prior_vol:
|
||||||
|
volatility_change_pct = round(volatility_pct - prior_vol[-1], 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": round(price, 4),
|
||||||
|
"change": round(change, 4),
|
||||||
|
"change_pct": round(change_pct, 2),
|
||||||
|
"volatility_pct": round(volatility_pct, 2),
|
||||||
|
"volatility_change_pct": volatility_change_pct,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"source": "saxo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_option_root_uic(symbol: str) -> int:
|
def resolve_option_root_uic(symbol: str) -> int:
|
||||||
return resolve_instrument(symbol)["uic"]
|
return resolve_instrument(symbol)["uic"]
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,10 @@ export default function Dashboard() {
|
|||||||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
||||||
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
|
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
|
||||||
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap">
|
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap">
|
||||||
<span className="text-slate-300 font-mono shrink-0">{it.ticker}</span>
|
<span className="text-slate-300 font-mono shrink-0 flex items-center gap-1">
|
||||||
|
{it.ticker}
|
||||||
|
{it.quote_source === 'saxo' && <span className="text-emerald-500" title="Priced from Saxo, not yfinance">●</span>}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
|
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
|
||||||
<span className={clsx('font-mono font-bold w-11 text-right shrink-0', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
<span className={clsx('font-mono font-bold w-11 text-right shrink-0', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
|||||||
BIN
matrice_regimes_corrigee.xlsx
Normal file
BIN
matrice_regimes_corrigee.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user