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