feat: cockpit

This commit is contained in:
OpenSquared
2026-07-27 08:29:55 +02:00
parent a08e8e1b11
commit ce09159bfb
4 changed files with 48 additions and 12 deletions

View File

@@ -63,7 +63,8 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
days_to_expiry = T * 365 days_to_expiry = T * 365
r = 0.05 r = 0.05
chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1)) chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1),
sanity_reference=pos.get("entry_underlying_price"))
# yfinance fallback inputs — only actually fetched if no usable Saxo chain, so a # yfinance fallback inputs — only actually fetched if no usable Saxo chain, so a
# Saxo-linked instrument never pays for a yfinance round-trip it doesn't need. # Saxo-linked instrument never pays for a yfinance round-trip it doesn't need.
@@ -276,13 +277,18 @@ def add_pos(req: AddPositionRequest):
# Underlying price — Saxo option chain's own spot first (real, and consistent with # Underlying price — Saxo option chain's own spot first (real, and consistent with
# whatever prices the legs below), yfinance only if this underlying has no # whatever prices the legs below), yfinance only if this underlying has no
# saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). # saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). The
chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90)) # yfinance quote is fetched unconditionally (cheap, one-time at creation) to also
# serve as resolve_saxo_chain's sanity_reference — guards against a Saxo chain whose
# spot is on a different scale (observed on COMEX copper, ~100x too large).
q = get_quote(normalized)
yf_spot = q.get("price") if q else None
chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90),
sanity_reference=yf_spot)
S = chain["spot"] if chain else None S = chain["spot"] if chain else None
sigma = None sigma = None
if S is None: if S is None:
q = get_quote(normalized) S = yf_spot
S = q.get("price") if q else None
if S is not None: if S is not None:
sigma = compute_historical_iv(req.underlying) sigma = compute_historical_iv(req.underlying)
if not S: if not S:

View File

@@ -25,10 +25,23 @@ from typing import Any, Dict, Optional, Tuple
from services.options_pricer import black_scholes from services.options_pricer import black_scholes
def resolve_saxo_chain(underlying: str, target_days: int) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]: def resolve_saxo_chain(
underlying: str, target_days: int, sanity_reference: Optional[float] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]:
"""Returns (chain_slice, Surface) for this underlying's linked Saxo option chain, or """Returns (chain_slice, Surface) for this underlying's linked Saxo option chain, or
(None, None) if it isn't linked, or the chain can't be built right now (Saxo down, no (None, None) if it isn't linked, or the chain can't be built right now (Saxo down, no
snapshot yet, entitlement gap, etc.) — callers fall back to yfinance pricing in that case.""" snapshot yet, entitlement gap, etc.) — callers fall back to yfinance pricing in that case.
`sanity_reference` (typically the position's entry_underlying_price, or a fresh
yfinance quote at creation time) guards against a chain whose "spot" is on a different
scale than the rest of the app expects. Saxo's option-chain snapshot has no dedicated
underlying-quote field (see saxo_client.snapshot_options_chain's MidStrikePrice-as-spot
comment), so for at least one real instrument (COMEX copper, HG=F) it has been observed
coming back ~100x too large — silently corrupting every downstream Black-Scholes reprice
for that position (current spot showing $649 against a real ~$6.3/lb). A >5x or <0.2x
deviation from a known-good reference is never a real intraday/short-term move for the
instruments this app trades, so it's treated as a mis-scaled/wrong quote rather than a
genuine price — safer to fall back to yfinance than to trust an unverifiable number."""
from services.database import get_saxo_option_symbol_for_ticker from services.database import get_saxo_option_symbol_for_ticker
saxo_symbol = get_saxo_option_symbol_for_ticker(underlying) saxo_symbol = get_saxo_option_symbol_for_ticker(underlying)
if not saxo_symbol: if not saxo_symbol:
@@ -37,9 +50,20 @@ def resolve_saxo_chain(underlying: str, target_days: int) -> Tuple[Optional[Dict
from services.option_chain import get_chain_slice from services.option_chain import get_chain_slice
from services.vol_surface import Surface from services.vol_surface import Surface
chain = get_chain_slice(saxo_symbol, target_days=max(target_days, 1)) chain = get_chain_slice(saxo_symbol, target_days=max(target_days, 1))
if not chain.get("spot"): spot = chain.get("spot")
if not spot:
return None, None return None, None
surface = Surface(chain["spot"], chain["expiries"]) if sanity_reference and sanity_reference > 0:
ratio = spot / sanity_reference
if ratio > 5 or ratio < 0.2:
import logging
logging.getLogger(__name__).warning(
f"[portfolio_pricing] Saxo chain spot for '{underlying}' ({spot}) is "
f"{ratio:.1f}x the reference ({sanity_reference}) — treating as a "
f"mis-scaled/unusable quote, falling back to yfinance."
)
return None, None
surface = Surface(spot, chain["expiries"])
return chain, surface return chain, surface
except Exception: except Exception:
return None, None return None, None
@@ -106,7 +130,8 @@ def compute_payoff(pos: Dict[str, Any], n_points: int = 61, range_pct: float = 0
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days) days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
r = 0.05 r = 0.05
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1)) chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1),
sanity_reference=pos.get("entry_underlying_price"))
fallback_spot = pos.get("entry_underlying_price") or 100.0 fallback_spot = pos.get("entry_underlying_price") or 100.0
fallback_sigma = 0.20 fallback_sigma = 0.20
if chain is None: if chain is None:

View File

@@ -138,7 +138,8 @@ def _resolve_position_market(pos: Dict[str, Any]):
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days) days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1)) chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1),
sanity_reference=pos.get("entry_underlying_price"))
fallback_spot = pos.get("entry_underlying_price") or 100.0 fallback_spot = pos.get("entry_underlying_price") or 100.0
fallback_sigma = 0.20 fallback_sigma = 0.20
if chain is None: if chain is None:

View File

@@ -109,7 +109,11 @@ export default function Dashboard() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore() const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
const { data: allQuotes } = useAllQuotes() const { data: allQuotes } = useAllQuotes()
const { data: ecoCalendarData } = useEcoCalendar({ period: 'recent', limit: 150, impacts: 'high,medium,low' }) // limit must comfortably cover the full ±(7d past, 14d future) "recent" window across all
// currencies/impacts — ORDER BY event_date ASC means a too-small limit gets entirely
// consumed by the past-week + very-near-term events, silently truncating this week's
// later days (e.g. Tue-Thu) out of the response before the frontend ever sees them.
const { data: ecoCalendarData } = useEcoCalendar({ period: 'recent', limit: 400, impacts: 'high,medium,low' })
const { data: portfolio } = usePortfolioSummary() const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores() const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns() const { data: allPatternsData } = useAllPatterns()