diff --git a/backend/routers/portfolio.py b/backend/routers/portfolio.py index 34206a7..2288dd6 100644 --- a/backend/routers/portfolio.py +++ b/backend/routers/portfolio.py @@ -63,7 +63,8 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: days_to_expiry = T * 365 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 # 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 # whatever prices the legs below), yfinance only if this underlying has no - # saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). - chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90)) + # saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). The + # 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 sigma = None if S is None: - q = get_quote(normalized) - S = q.get("price") if q else None + S = yf_spot if S is not None: sigma = compute_historical_iv(req.underlying) if not S: diff --git a/backend/services/portfolio_pricing.py b/backend/services/portfolio_pricing.py index 652e84e..ad3f0b9 100644 --- a/backend/services/portfolio_pricing.py +++ b/backend/services/portfolio_pricing.py @@ -25,10 +25,23 @@ from typing import Any, Dict, Optional, Tuple 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 (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 saxo_symbol = get_saxo_option_symbol_for_ticker(underlying) 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.vol_surface import Surface 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 - 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 except Exception: 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) 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_sigma = 0.20 if chain is None: diff --git a/backend/services/portfolio_scenarios.py b/backend/services/portfolio_scenarios.py index 95dda35..d08cafa 100644 --- a/backend/services/portfolio_scenarios.py +++ b/backend/services/portfolio_scenarios.py @@ -138,7 +138,8 @@ def _resolve_position_market(pos: Dict[str, Any]): entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() 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_sigma = 0.20 if chain is None: diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 302f0b7..ae196bd 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -109,7 +109,11 @@ export default function Dashboard() { const navigate = useNavigate() const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore() 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: lastScoresData } = useLastScores() const { data: allPatternsData } = useAllPatterns()