From de03bd0064e7949b45973716ac25ffcb94ee0d60 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Mon, 23 Mar 2026 23:46:40 +0100 Subject: [PATCH] feat(DATA-003): calculate live option greeks --- app/core/calculations.py | 83 +++++++++++++++++++++++++++++- app/pages/options.py | 2 +- app/pages/overview.py | 4 +- app/services/data_service.py | 69 +++++++++++++------------ tests/test_option_greeks.py | 97 ++++++++++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 tests/test_option_greeks.py diff --git a/app/core/calculations.py b/app/core/calculations.py index 1f5dbf5..bc6b772 100644 --- a/app/core/calculations.py +++ b/app/core/calculations.py @@ -1,7 +1,14 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping +from datetime import date, datetime +from app.core.pricing.black_scholes import ( + DEFAULT_RISK_FREE_RATE, + DEFAULT_VOLATILITY, + BlackScholesInputs, + black_scholes_price_and_greeks, +) from app.models.option import OptionContract from app.models.portfolio import LombardPortfolio from app.models.strategy import HedgingStrategy @@ -96,3 +103,77 @@ def portfolio_net_equity( hedge_cost=hedge_cost, option_payoff_value=payoff_value, ) + + +_ZERO_GREEKS = {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0, "rho": 0.0} + + +def option_row_greeks( + row: Mapping[str, object], + underlying_price: float, + *, + risk_free_rate: float = DEFAULT_RISK_FREE_RATE, + valuation_date: date | None = None, +) -> dict[str, float]: + """Calculate Black-Scholes Greeks for an option-chain row. + + Prefers live implied volatility when available. If it is missing or invalid, + a conservative default volatility is used. Invalid or expired rows return + zero Greeks instead of raising. + """ + if underlying_price <= 0: + return dict(_ZERO_GREEKS) + + try: + strike = float(row.get("strike", 0.0)) + except (TypeError, ValueError): + return dict(_ZERO_GREEKS) + if strike <= 0: + return dict(_ZERO_GREEKS) + + option_type = str(row.get("type", "")).lower() + if option_type not in {"call", "put"}: + return dict(_ZERO_GREEKS) + + expiry_raw = row.get("expiry") + if not isinstance(expiry_raw, str) or not expiry_raw: + return dict(_ZERO_GREEKS) + + try: + expiry = datetime.fromisoformat(expiry_raw).date() + except ValueError: + return dict(_ZERO_GREEKS) + + valuation = valuation_date or date.today() + days_to_expiry = (expiry - valuation).days + if days_to_expiry <= 0: + return dict(_ZERO_GREEKS) + + try: + implied_volatility = float(row.get("impliedVolatility", 0.0) or 0.0) + except (TypeError, ValueError): + implied_volatility = 0.0 + volatility = implied_volatility if implied_volatility > 0 else DEFAULT_VOLATILITY + + try: + pricing = black_scholes_price_and_greeks( + BlackScholesInputs( + spot=underlying_price, + strike=strike, + time_to_expiry=days_to_expiry / 365.0, + risk_free_rate=risk_free_rate, + volatility=volatility, + option_type=option_type, + valuation_date=valuation, + ) + ) + except ValueError: + return dict(_ZERO_GREEKS) + + return { + "delta": pricing.delta, + "gamma": pricing.gamma, + "theta": pricing.theta, + "vega": pricing.vega, + "rho": pricing.rho, + } diff --git a/app/pages/options.py b/app/pages/options.py index b3fef59..3d6818c 100644 --- a/app/pages/options.py +++ b/app/pages/options.py @@ -136,7 +136,7 @@ async def options_page() -> None: ${float(row['bid']):.2f} / ${float(row['ask']):.2f} ${float(row.get('lastPrice', row.get('premium', 0.0))):.2f} {float(row.get('impliedVolatility', 0.0)):.1%} - Δ {float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f} + Δ {float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f} · V {float(row.get('vega', 0.0)):.3f} Use quick-add buttons below """ diff --git a/app/pages/overview.py b/app/pages/overview.py index 229bc3e..1398985 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime, timezone from nicegui import ui @@ -20,7 +20,7 @@ def _format_timestamp(value: str | None) -> str: timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return value - return timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + return timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]: diff --git a/app/services/data_service.py b/app/services/data_service.py index 1996eea..20e8a67 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -5,9 +5,10 @@ from __future__ import annotations import asyncio import logging import math -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any +from app.core.calculations import option_row_greeks from app.services.cache import CacheService from app.strategies.engine import StrategySelectionEngine @@ -41,7 +42,7 @@ class DataService: "portfolio_value": round(quote["price"] * 1000, 2), "loan_amount": 600_000.0, "ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4), - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "source": quote["source"], } await self.cache.set_json(cache_key, portfolio) @@ -91,7 +92,7 @@ class DataService: payload = { "symbol": ticker_symbol, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "expirations": expirations, "underlying_price": quote["price"], "source": "yfinance", @@ -146,8 +147,8 @@ class DataService: try: ticker = yf.Ticker(ticker_symbol) chain = await asyncio.to_thread(ticker.option_chain, target_expiry) - calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call") - puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put") + calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call", quote["price"]) + puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put", quote["price"]) if not calls and not puts: payload = self._fallback_options_chain( @@ -164,7 +165,7 @@ class DataService: payload = { "symbol": ticker_symbol, "selected_expiry": target_expiry, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "expirations": expirations, "calls": calls, "puts": puts, @@ -210,7 +211,7 @@ class DataService: return { "symbol": ticker, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "paper_parameters": { "portfolio_value": engine.portfolio_value, "loan_amount": engine.loan_amount, @@ -247,7 +248,7 @@ class DataService: "price": round(last, 4), "change": change, "change_percent": change_percent, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "source": "yfinance", } except Exception as exc: # pragma: no cover - network dependent @@ -264,7 +265,7 @@ class DataService: ) -> dict[str, Any]: payload = { "symbol": symbol, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "expirations": [], "underlying_price": quote["price"], "source": source, @@ -286,7 +287,7 @@ class DataService: options_chain = { "symbol": symbol, "selected_expiry": selected_expiry, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "expirations": expirations, "calls": [], "puts": [], @@ -298,7 +299,14 @@ class DataService: options_chain["error"] = error return options_chain - def _normalize_option_rows(self, frame: Any, symbol: str, expiry: str, option_type: str) -> list[dict[str, Any]]: + def _normalize_option_rows( + self, + frame: Any, + symbol: str, + expiry: str, + option_type: str, + underlying_price: float, + ) -> list[dict[str, Any]]: if frame is None or getattr(frame, "empty", True): return [] @@ -314,27 +322,22 @@ class DataService: implied_volatility = self._safe_float(item.get("impliedVolatility")) contract_symbol = str(item.get("contractSymbol") or "").strip() - rows.append( - { - "contractSymbol": contract_symbol, - "symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}", - "strike": strike, - "bid": bid, - "ask": ask, - "premium": last_price or self._midpoint(bid, ask), - "lastPrice": last_price, - "impliedVolatility": implied_volatility, - "expiry": expiry, - "type": option_type, - "openInterest": int(self._safe_float(item.get("openInterest"))), - "volume": int(self._safe_float(item.get("volume"))), - "delta": 0.0, - "gamma": 0.0, - "theta": 0.0, - "vega": 0.0, - "rho": 0.0, - } - ) + row = { + "contractSymbol": contract_symbol, + "symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}", + "strike": strike, + "bid": bid, + "ask": ask, + "premium": last_price or self._midpoint(bid, ask), + "lastPrice": last_price, + "impliedVolatility": implied_volatility, + "expiry": expiry, + "type": option_type, + "openInterest": int(self._safe_float(item.get("openInterest"))), + "volume": int(self._safe_float(item.get("volume"))), + } + row.update(option_row_greeks(row, underlying_price)) + rows.append(row) return rows @staticmethod @@ -358,6 +361,6 @@ class DataService: "price": 215.0, "change": 0.0, "change_percent": 0.0, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "source": source, } diff --git a/tests/test_option_greeks.py b/tests/test_option_greeks.py new file mode 100644 index 0000000..e71c6ee --- /dev/null +++ b/tests/test_option_greeks.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from datetime import datetime + +import pandas as pd +import pytest + +from app.core.calculations import option_row_greeks +from app.core.pricing.black_scholes import ( + DEFAULT_RISK_FREE_RATE, + DEFAULT_VOLATILITY, + BlackScholesInputs, + black_scholes_price_and_greeks, +) +from app.services.cache import CacheService +from app.services.data_service import DataService + + +@pytest.mark.parametrize( + "row, expected_volatility", + [ + ( + { + "strike": 460.0, + "expiry": "2026-06-19", + "type": "put", + "impliedVolatility": 0.24, + }, + 0.24, + ), + ( + { + "strike": 460.0, + "expiry": "2026-06-19", + "type": "put", + "impliedVolatility": 0.0, + }, + DEFAULT_VOLATILITY, + ), + ], +) +def test_option_row_greeks_uses_live_iv_or_falls_back(row: dict[str, object], expected_volatility: float) -> None: + valuation_date = datetime(2026, 3, 23).date() + + greeks = option_row_greeks(row, underlying_price=460.0, valuation_date=valuation_date) + expected = black_scholes_price_and_greeks( + BlackScholesInputs( + spot=460.0, + strike=460.0, + time_to_expiry=(datetime(2026, 6, 19).date() - valuation_date).days / 365.0, + risk_free_rate=DEFAULT_RISK_FREE_RATE, + volatility=expected_volatility, + option_type="put", + valuation_date=valuation_date, + ) + ) + + assert greeks["delta"] == pytest.approx(expected.delta, rel=1e-9) + assert greeks["gamma"] == pytest.approx(expected.gamma, rel=1e-9) + assert greeks["theta"] == pytest.approx(expected.theta, rel=1e-9) + assert greeks["vega"] == pytest.approx(expected.vega, rel=1e-9) + + +def test_option_row_greeks_handles_invalid_input_gracefully() -> None: + greeks = option_row_greeks( + {"strike": 0.0, "expiry": "not-a-date", "type": "call", "impliedVolatility": -1.0}, + underlying_price=0.0, + ) + + assert greeks == {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0, "rho": 0.0} + + +def test_normalize_option_rows_populates_greeks() -> None: + frame = pd.DataFrame( + [ + { + "contractSymbol": "GLD260619P00460000", + "strike": 460.0, + "bid": 18.5, + "ask": 19.5, + "lastPrice": 19.0, + "impliedVolatility": 0.22, + "openInterest": 100, + "volume": 50, + } + ] + ) + service = DataService(CacheService(None)) + + rows = service._normalize_option_rows(frame, "GLD", "2026-06-19", "put", 460.0) + + assert len(rows) == 1 + assert rows[0]["symbol"] == "GLD260619P00460000" + assert rows[0]["delta"] < 0.0 + assert rows[0]["gamma"] > 0.0 + assert rows[0]["theta"] < 0.0 + assert rows[0]["vega"] > 0.0