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 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,
}

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.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('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>
</tr>
"""

View File

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

View File

@@ -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,8 +322,7 @@ class DataService:
implied_volatility = self._safe_float(item.get("impliedVolatility"))
contract_symbol = str(item.get("contractSymbol") or "").strip()
rows.append(
{
row = {
"contractSymbol": contract_symbol,
"symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}",
"strike": strike,
@@ -328,13 +335,9 @@ class DataService:
"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.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,
}

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