feat(DATA-003): calculate live option greeks

This commit is contained in:
Bu5hm4nn
2026-03-23 23:46:40 +01:00
parent 46ce81d2d6
commit de03bd0064
5 changed files with 218 additions and 37 deletions

View File

@@ -1,7 +1,14 @@
from __future__ import annotations 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.option import OptionContract
from app.models.portfolio import LombardPortfolio from app.models.portfolio import LombardPortfolio
from app.models.strategy import HedgingStrategy from app.models.strategy import HedgingStrategy
@@ -96,3 +103,77 @@ def portfolio_net_equity(
hedge_cost=hedge_cost, hedge_cost=hedge_cost,
option_payoff_value=payoff_value, 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,
}

View File

@@ -136,7 +136,7 @@ async def options_page() -> None:
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['bid']):.2f} / ${float(row['ask']):.2f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['bid']):.2f} / ${float(row['ask']):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row.get('lastPrice', row.get('premium', 0.0))):.2f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row.get('lastPrice', row.get('premium', 0.0))):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{float(row.get('impliedVolatility', 0.0)):.1%}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{float(row.get('impliedVolatility', 0.0)):.1%}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'{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}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td> <td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr> </tr>
""" """

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import datetime, timezone
from nicegui import ui from nicegui import ui
@@ -20,7 +20,7 @@ def _format_timestamp(value: str | None) -> str:
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError: except ValueError:
return value 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]: def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]:

View File

@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math import math
from datetime import UTC, datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from app.core.calculations import option_row_greeks
from app.services.cache import CacheService from app.services.cache import CacheService
from app.strategies.engine import StrategySelectionEngine from app.strategies.engine import StrategySelectionEngine
@@ -41,7 +42,7 @@ class DataService:
"portfolio_value": round(quote["price"] * 1000, 2), "portfolio_value": round(quote["price"] * 1000, 2),
"loan_amount": 600_000.0, "loan_amount": 600_000.0,
"ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4), "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"], "source": quote["source"],
} }
await self.cache.set_json(cache_key, portfolio) await self.cache.set_json(cache_key, portfolio)
@@ -91,7 +92,7 @@ class DataService:
payload = { payload = {
"symbol": ticker_symbol, "symbol": ticker_symbol,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": expirations, "expirations": expirations,
"underlying_price": quote["price"], "underlying_price": quote["price"],
"source": "yfinance", "source": "yfinance",
@@ -146,8 +147,8 @@ class DataService:
try: try:
ticker = yf.Ticker(ticker_symbol) ticker = yf.Ticker(ticker_symbol)
chain = await asyncio.to_thread(ticker.option_chain, target_expiry) chain = await asyncio.to_thread(ticker.option_chain, target_expiry)
calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call") 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") puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put", quote["price"])
if not calls and not puts: if not calls and not puts:
payload = self._fallback_options_chain( payload = self._fallback_options_chain(
@@ -164,7 +165,7 @@ class DataService:
payload = { payload = {
"symbol": ticker_symbol, "symbol": ticker_symbol,
"selected_expiry": target_expiry, "selected_expiry": target_expiry,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": expirations, "expirations": expirations,
"calls": calls, "calls": calls,
"puts": puts, "puts": puts,
@@ -210,7 +211,7 @@ class DataService:
return { return {
"symbol": ticker, "symbol": ticker,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"paper_parameters": { "paper_parameters": {
"portfolio_value": engine.portfolio_value, "portfolio_value": engine.portfolio_value,
"loan_amount": engine.loan_amount, "loan_amount": engine.loan_amount,
@@ -247,7 +248,7 @@ class DataService:
"price": round(last, 4), "price": round(last, 4),
"change": change, "change": change,
"change_percent": change_percent, "change_percent": change_percent,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"source": "yfinance", "source": "yfinance",
} }
except Exception as exc: # pragma: no cover - network dependent except Exception as exc: # pragma: no cover - network dependent
@@ -264,7 +265,7 @@ class DataService:
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = { payload = {
"symbol": symbol, "symbol": symbol,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": [], "expirations": [],
"underlying_price": quote["price"], "underlying_price": quote["price"],
"source": source, "source": source,
@@ -286,7 +287,7 @@ class DataService:
options_chain = { options_chain = {
"symbol": symbol, "symbol": symbol,
"selected_expiry": selected_expiry, "selected_expiry": selected_expiry,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": expirations, "expirations": expirations,
"calls": [], "calls": [],
"puts": [], "puts": [],
@@ -298,7 +299,14 @@ class DataService:
options_chain["error"] = error options_chain["error"] = error
return options_chain 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): if frame is None or getattr(frame, "empty", True):
return [] return []
@@ -314,8 +322,7 @@ class DataService:
implied_volatility = self._safe_float(item.get("impliedVolatility")) implied_volatility = self._safe_float(item.get("impliedVolatility"))
contract_symbol = str(item.get("contractSymbol") or "").strip() contract_symbol = str(item.get("contractSymbol") or "").strip()
rows.append( row = {
{
"contractSymbol": contract_symbol, "contractSymbol": contract_symbol,
"symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}", "symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}",
"strike": strike, "strike": strike,
@@ -328,13 +335,9 @@ class DataService:
"type": option_type, "type": option_type,
"openInterest": int(self._safe_float(item.get("openInterest"))), "openInterest": int(self._safe_float(item.get("openInterest"))),
"volume": int(self._safe_float(item.get("volume"))), "volume": int(self._safe_float(item.get("volume"))),
"delta": 0.0,
"gamma": 0.0,
"theta": 0.0,
"vega": 0.0,
"rho": 0.0,
} }
) row.update(option_row_greeks(row, underlying_price))
rows.append(row)
return rows return rows
@staticmethod @staticmethod
@@ -358,6 +361,6 @@ class DataService:
"price": 215.0, "price": 215.0,
"change": 0.0, "change": 0.0,
"change_percent": 0.0, "change_percent": 0.0,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"source": source, "source": source,
} }

View File

@@ -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