feat(DATA-003): calculate live option greeks
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
97
tests/test_option_greeks.py
Normal file
97
tests/test_option_greeks.py
Normal 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
|
||||
Reference in New Issue
Block a user