Initial commit: Vault Dashboard for options hedging
- FastAPI + NiceGUI web application - QuantLib-based Black-Scholes pricing with Greeks - Protective put, laddered, and LEAPS strategies - Real-time WebSocket updates - TradingView-style charts via Lightweight-Charts - Docker containerization - GitLab CI/CD pipeline for VPS deployment - VPN-only access configuration
This commit is contained in:
15
app/models/__init__.py
Normal file
15
app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Application domain models."""
|
||||
|
||||
from .option import Greeks, OptionContract, OptionMoneyness
|
||||
from .portfolio import LombardPortfolio
|
||||
from .strategy import HedgingStrategy, ScenarioResult, StrategyType
|
||||
|
||||
__all__ = [
|
||||
"Greeks",
|
||||
"HedgingStrategy",
|
||||
"LombardPortfolio",
|
||||
"OptionContract",
|
||||
"OptionMoneyness",
|
||||
"ScenarioResult",
|
||||
"StrategyType",
|
||||
]
|
||||
109
app/models/option.py
Normal file
109
app/models/option.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
OptionType = Literal["call", "put"]
|
||||
OptionMoneyness = Literal["ITM", "ATM", "OTM"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Greeks:
|
||||
"""Option Greeks container."""
|
||||
|
||||
delta: float = 0.0
|
||||
gamma: float = 0.0
|
||||
theta: float = 0.0
|
||||
vega: float = 0.0
|
||||
rho: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptionContract:
|
||||
"""Vanilla option contract used in hedging strategies.
|
||||
|
||||
Attributes:
|
||||
option_type: Contract type, either ``"put"`` or ``"call"``.
|
||||
strike: Strike price.
|
||||
expiry: Expiration date.
|
||||
premium: Premium paid or received per unit of underlying.
|
||||
quantity: Number of contracts or units.
|
||||
contract_size: Underlying units per contract.
|
||||
underlying_price: Current underlying spot price for classification.
|
||||
greeks: Stored option Greeks.
|
||||
"""
|
||||
|
||||
option_type: OptionType
|
||||
strike: float
|
||||
expiry: date
|
||||
premium: float
|
||||
quantity: float = 1.0
|
||||
contract_size: float = 1.0
|
||||
underlying_price: float | None = None
|
||||
greeks: Greeks = Greeks()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
option = self.option_type.lower()
|
||||
if option not in {"call", "put"}:
|
||||
raise ValueError("option_type must be either 'call' or 'put'")
|
||||
object.__setattr__(self, "option_type", option)
|
||||
|
||||
if self.strike <= 0:
|
||||
raise ValueError("strike must be positive")
|
||||
if self.premium < 0:
|
||||
raise ValueError("premium must be non-negative")
|
||||
if self.quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
if self.contract_size <= 0:
|
||||
raise ValueError("contract_size must be positive")
|
||||
if self.expiry <= date.today():
|
||||
raise ValueError("expiry must be in the future")
|
||||
if self.underlying_price is not None and self.underlying_price <= 0:
|
||||
raise ValueError("underlying_price must be positive when provided")
|
||||
|
||||
@property
|
||||
def notional_units(self) -> float:
|
||||
"""Underlying units covered by the contract position."""
|
||||
return self.quantity * self.contract_size
|
||||
|
||||
@property
|
||||
def total_premium(self) -> float:
|
||||
"""Total premium paid or received for the position."""
|
||||
return self.premium * self.notional_units
|
||||
|
||||
def classify_moneyness(self, underlying_price: float | None = None, *, atm_tolerance: float = 0.01) -> OptionMoneyness:
|
||||
"""Classify the contract as ITM, ATM, or OTM.
|
||||
|
||||
Args:
|
||||
underlying_price: Spot price used for classification. Falls back to
|
||||
``self.underlying_price``.
|
||||
atm_tolerance: Relative tolerance around strike treated as at-the-money.
|
||||
"""
|
||||
spot = self.underlying_price if underlying_price is None else underlying_price
|
||||
if spot is None:
|
||||
raise ValueError("underlying_price must be provided for strategy classification")
|
||||
if spot <= 0:
|
||||
raise ValueError("underlying_price must be positive")
|
||||
if atm_tolerance < 0:
|
||||
raise ValueError("atm_tolerance must be non-negative")
|
||||
|
||||
relative_gap = abs(spot - self.strike) / self.strike
|
||||
if relative_gap <= atm_tolerance:
|
||||
return "ATM"
|
||||
|
||||
if self.option_type == "put":
|
||||
return "ITM" if self.strike > spot else "OTM"
|
||||
return "ITM" if self.strike < spot else "OTM"
|
||||
|
||||
def intrinsic_value(self, underlying_price: float) -> float:
|
||||
"""Intrinsic value per underlying unit at a given spot price."""
|
||||
if underlying_price <= 0:
|
||||
raise ValueError("underlying_price must be positive")
|
||||
if self.option_type == "put":
|
||||
return max(self.strike - underlying_price, 0.0)
|
||||
return max(underlying_price - self.strike, 0.0)
|
||||
|
||||
def payoff(self, underlying_price: float) -> float:
|
||||
"""Gross payoff of the option position at expiry."""
|
||||
return self.intrinsic_value(underlying_price) * self.notional_units
|
||||
71
app/models/portfolio.py
Normal file
71
app/models/portfolio.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LombardPortfolio:
|
||||
"""Lombard loan portfolio backed by physical gold.
|
||||
|
||||
Attributes:
|
||||
gold_ounces: Quantity of pledged gold in troy ounces.
|
||||
gold_price_per_ounce: Current gold spot price per ounce.
|
||||
loan_amount: Outstanding Lombard loan balance.
|
||||
initial_ltv: Origination or current reference loan-to-value ratio.
|
||||
margin_call_ltv: LTV threshold at which a margin call is triggered.
|
||||
"""
|
||||
|
||||
gold_ounces: float
|
||||
gold_price_per_ounce: float
|
||||
loan_amount: float
|
||||
initial_ltv: float
|
||||
margin_call_ltv: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.gold_ounces <= 0:
|
||||
raise ValueError("gold_ounces must be positive")
|
||||
if self.gold_price_per_ounce <= 0:
|
||||
raise ValueError("gold_price_per_ounce must be positive")
|
||||
if self.loan_amount < 0:
|
||||
raise ValueError("loan_amount must be non-negative")
|
||||
if not 0 < self.initial_ltv < 1:
|
||||
raise ValueError("initial_ltv must be between 0 and 1")
|
||||
if not 0 < self.margin_call_ltv < 1:
|
||||
raise ValueError("margin_call_ltv must be between 0 and 1")
|
||||
if self.initial_ltv > self.margin_call_ltv:
|
||||
raise ValueError("initial_ltv cannot exceed margin_call_ltv")
|
||||
if self.loan_amount > self.gold_value:
|
||||
raise ValueError("loan_amount cannot exceed current gold value")
|
||||
|
||||
@property
|
||||
def gold_value(self) -> float:
|
||||
"""Current market value of pledged gold."""
|
||||
return self.gold_ounces * self.gold_price_per_ounce
|
||||
|
||||
@property
|
||||
def current_ltv(self) -> float:
|
||||
"""Current loan-to-value ratio."""
|
||||
return self.loan_amount / self.gold_value
|
||||
|
||||
@property
|
||||
def net_equity(self) -> float:
|
||||
"""Equity remaining after subtracting the loan from gold value."""
|
||||
return self.gold_value - self.loan_amount
|
||||
|
||||
def gold_value_at_price(self, gold_price_per_ounce: float) -> float:
|
||||
"""Gold value under an alternative spot-price scenario."""
|
||||
if gold_price_per_ounce <= 0:
|
||||
raise ValueError("gold_price_per_ounce must be positive")
|
||||
return self.gold_ounces * gold_price_per_ounce
|
||||
|
||||
def ltv_at_price(self, gold_price_per_ounce: float) -> float:
|
||||
"""Portfolio LTV under an alternative gold-price scenario."""
|
||||
return self.loan_amount / self.gold_value_at_price(gold_price_per_ounce)
|
||||
|
||||
def net_equity_at_price(self, gold_price_per_ounce: float) -> float:
|
||||
"""Net equity under an alternative gold-price scenario."""
|
||||
return self.gold_value_at_price(gold_price_per_ounce) - self.loan_amount
|
||||
|
||||
def margin_call_price(self) -> float:
|
||||
"""Gold price per ounce at which the portfolio breaches the margin LTV."""
|
||||
return self.loan_amount / (self.margin_call_ltv * self.gold_ounces)
|
||||
101
app/models/strategy.py
Normal file
101
app/models/strategy.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from .option import OptionContract
|
||||
|
||||
StrategyType = Literal["single_put", "laddered_put", "collar"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioResult:
|
||||
"""Scenario output for a hedging strategy."""
|
||||
|
||||
underlying_price: float
|
||||
gross_option_payoff: float
|
||||
hedge_cost: float
|
||||
net_option_benefit: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HedgingStrategy:
|
||||
"""Collection of option positions representing a hedge.
|
||||
|
||||
Notes:
|
||||
Premiums on long positions are positive cash outflows. Premiums on
|
||||
short positions are handled through ``short_contracts`` and reduce the
|
||||
total hedge cost.
|
||||
"""
|
||||
|
||||
strategy_type: StrategyType
|
||||
long_contracts: tuple[OptionContract, ...] = field(default_factory=tuple)
|
||||
short_contracts: tuple[OptionContract, ...] = field(default_factory=tuple)
|
||||
description: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.strategy_type not in {"single_put", "laddered_put", "collar"}:
|
||||
raise ValueError("unsupported strategy_type")
|
||||
if not self.long_contracts and not self.short_contracts:
|
||||
raise ValueError("at least one option contract is required")
|
||||
|
||||
if self.strategy_type == "single_put":
|
||||
if len(self.long_contracts) != 1 or self.long_contracts[0].option_type != "put":
|
||||
raise ValueError("single_put requires exactly one long put contract")
|
||||
if self.short_contracts:
|
||||
raise ValueError("single_put cannot include short contracts")
|
||||
|
||||
if self.strategy_type == "laddered_put":
|
||||
if len(self.long_contracts) < 2:
|
||||
raise ValueError("laddered_put requires at least two long put contracts")
|
||||
if any(contract.option_type != "put" for contract in self.long_contracts):
|
||||
raise ValueError("laddered_put supports only long put contracts")
|
||||
if self.short_contracts:
|
||||
raise ValueError("laddered_put cannot include short contracts")
|
||||
|
||||
if self.strategy_type == "collar":
|
||||
if not self.long_contracts or not self.short_contracts:
|
||||
raise ValueError("collar requires both long and short contracts")
|
||||
if any(contract.option_type != "put" for contract in self.long_contracts):
|
||||
raise ValueError("collar long leg must be put options")
|
||||
if any(contract.option_type != "call" for contract in self.short_contracts):
|
||||
raise ValueError("collar short leg must be call options")
|
||||
|
||||
@property
|
||||
def hedge_cost(self) -> float:
|
||||
"""Net upfront hedge cost."""
|
||||
long_cost = sum(contract.total_premium for contract in self.long_contracts)
|
||||
short_credit = sum(contract.total_premium for contract in self.short_contracts)
|
||||
return long_cost - short_credit
|
||||
|
||||
def gross_payoff(self, underlying_price: float) -> float:
|
||||
"""Gross expiry payoff from all option legs."""
|
||||
if underlying_price <= 0:
|
||||
raise ValueError("underlying_price must be positive")
|
||||
long_payoff = sum(contract.payoff(underlying_price) for contract in self.long_contracts)
|
||||
short_payoff = sum(contract.payoff(underlying_price) for contract in self.short_contracts)
|
||||
return long_payoff - short_payoff
|
||||
|
||||
def net_benefit(self, underlying_price: float) -> float:
|
||||
"""Net value added by the hedge after premium cost."""
|
||||
return self.gross_payoff(underlying_price) - self.hedge_cost
|
||||
|
||||
def scenario_analysis(self, underlying_prices: list[float] | tuple[float, ...]) -> list[ScenarioResult]:
|
||||
"""Evaluate the hedge across alternative underlying-price scenarios."""
|
||||
if not underlying_prices:
|
||||
raise ValueError("underlying_prices must not be empty")
|
||||
|
||||
results: list[ScenarioResult] = []
|
||||
for price in underlying_prices:
|
||||
if price <= 0:
|
||||
raise ValueError("scenario prices must be positive")
|
||||
gross_payoff = self.gross_payoff(price)
|
||||
results.append(
|
||||
ScenarioResult(
|
||||
underlying_price=price,
|
||||
gross_option_payoff=gross_payoff,
|
||||
hedge_cost=self.hedge_cost,
|
||||
net_option_benefit=gross_payoff - self.hedge_cost,
|
||||
)
|
||||
)
|
||||
return results
|
||||
Reference in New Issue
Block a user