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:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
from .base import BaseStrategy, StrategyConfig
from .engine import StrategySelectionEngine
from .laddered_put import LadderSpec, LadderedPutStrategy
from .lease import LeaseAnalysisSpec, LeaseStrategy
from .protective_put import ProtectivePutSpec, ProtectivePutStrategy
__all__ = [
"BaseStrategy",
"StrategyConfig",
"ProtectivePutSpec",
"ProtectivePutStrategy",
"LadderSpec",
"LadderedPutStrategy",
"LeaseAnalysisSpec",
"LeaseStrategy",
"StrategySelectionEngine",
]

40
app/strategies/base.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from app.models.portfolio import LombardPortfolio
@dataclass(frozen=True)
class StrategyConfig:
"""Common research inputs used by all strategy implementations."""
portfolio: LombardPortfolio
spot_price: float
volatility: float
risk_free_rate: float
class BaseStrategy(ABC):
"""Abstract strategy interface for paper-based hedge analysis."""
def __init__(self, config: StrategyConfig) -> None:
self.config = config
@property
@abstractmethod
def name(self) -> str: # pragma: no cover - interface only
raise NotImplementedError
@abstractmethod
def calculate_cost(self) -> dict:
raise NotImplementedError
@abstractmethod
def calculate_protection(self) -> dict:
raise NotImplementedError
@abstractmethod
def get_scenarios(self) -> list[dict]:
raise NotImplementedError

159
app/strategies/engine.py Normal file
View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.core.pricing.black_scholes import DEFAULT_GLD_PRICE, DEFAULT_RISK_FREE_RATE, DEFAULT_VOLATILITY
from app.models.portfolio import LombardPortfolio
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.laddered_put import LadderSpec, LadderedPutStrategy
from app.strategies.lease import LeaseStrategy
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
RiskProfile = Literal["conservative", "balanced", "cost_sensitive"]
RESEARCH_PORTFOLIO_VALUE = 1_000_000.0
RESEARCH_LOAN_AMOUNT = 600_000.0
RESEARCH_MARGIN_CALL_THRESHOLD = 0.75
RESEARCH_GLD_SPOT = 460.0
RESEARCH_VOLATILITY = 0.16
RESEARCH_RISK_FREE_RATE = 0.045
@dataclass(frozen=True)
class StrategySelectionEngine:
"""Compare paper strategies and recommend the best fit by risk profile."""
portfolio_value: float = RESEARCH_PORTFOLIO_VALUE
loan_amount: float = RESEARCH_LOAN_AMOUNT
margin_call_threshold: float = RESEARCH_MARGIN_CALL_THRESHOLD
spot_price: float = RESEARCH_GLD_SPOT
volatility: float = RESEARCH_VOLATILITY
risk_free_rate: float = RESEARCH_RISK_FREE_RATE
def _config(self) -> StrategyConfig:
portfolio = LombardPortfolio(
gold_ounces=self.portfolio_value / self.spot_price,
gold_price_per_ounce=self.spot_price,
loan_amount=self.loan_amount,
initial_ltv=self.loan_amount / self.portfolio_value,
margin_call_ltv=self.margin_call_threshold,
)
return StrategyConfig(
portfolio=portfolio,
spot_price=self.spot_price,
volatility=self.volatility,
risk_free_rate=self.risk_free_rate,
)
def _strategies(self) -> list[BaseStrategy]:
config = self._config()
return [
ProtectivePutStrategy(config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12)),
ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_95", strike_pct=0.95, months=12)),
ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_90", strike_pct=0.90, months=12)),
LadderedPutStrategy(
config,
LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12),
),
LadderedPutStrategy(
config,
LadderSpec(label="33_33_33_ATM_OTM95_OTM90", weights=(1 / 3, 1 / 3, 1 / 3), strike_pcts=(1.0, 0.95, 0.90), months=12),
),
LeaseStrategy(config),
]
def compare_all_strategies(self) -> list[dict]:
comparisons: list[dict] = []
for strategy in self._strategies():
cost = strategy.calculate_cost()
protection = strategy.calculate_protection()
scenarios = strategy.get_scenarios()
annual_cost = cost.get("annualized_cost", cost.get("lowest_annual_cost", 0.0))
protection_ltv = protection.get("hedged_ltv_at_threshold")
if protection_ltv is None:
duration_rows = protection.get("durations", [])
protection_ltv = min((row["hedged_ltv_at_threshold"] for row in duration_rows), default=1.0)
comparisons.append(
{
"name": strategy.name,
"cost": cost,
"protection": protection,
"scenarios": scenarios,
"score_inputs": {
"annual_cost": annual_cost,
"hedged_ltv_at_threshold": protection_ltv,
},
}
)
return comparisons
def recommend(self, risk_profile: RiskProfile = "balanced") -> dict:
comparisons = self.compare_all_strategies()
def score(item: dict) -> tuple[float, float]:
annual_cost = item["score_inputs"]["annual_cost"]
hedged_ltv = item["score_inputs"]["hedged_ltv_at_threshold"]
if risk_profile == "conservative":
return (hedged_ltv, annual_cost)
if risk_profile == "cost_sensitive":
return (annual_cost, hedged_ltv)
return (hedged_ltv + (annual_cost / self.portfolio_value), annual_cost)
recommended = min(comparisons, key=score)
return {
"risk_profile": risk_profile,
"recommended_strategy": recommended["name"],
"rationale": {
"portfolio_value": self.portfolio_value,
"loan_amount": self.loan_amount,
"margin_call_threshold": self.margin_call_threshold,
"spot_price": self.spot_price,
"volatility": self.volatility,
"risk_free_rate": self.risk_free_rate,
},
"comparison_summary": [
{
"name": item["name"],
"annual_cost": round(item["score_inputs"]["annual_cost"], 2),
"hedged_ltv_at_threshold": round(item["score_inputs"]["hedged_ltv_at_threshold"], 6),
}
for item in comparisons
],
}
def sensitivity_analysis(self) -> dict:
results: dict[str, list[dict]] = {"volatility": [], "spot_price": []}
for volatility in (0.12, 0.16, 0.20):
engine = StrategySelectionEngine(
portfolio_value=self.portfolio_value,
loan_amount=self.loan_amount,
margin_call_threshold=self.margin_call_threshold,
spot_price=self.spot_price,
volatility=volatility,
risk_free_rate=self.risk_free_rate,
)
recommendation = engine.recommend("balanced")
results["volatility"].append(
{
"volatility": volatility,
"recommended_strategy": recommendation["recommended_strategy"],
}
)
for spot_price in (DEFAULT_GLD_PRICE * 0.9, DEFAULT_GLD_PRICE, DEFAULT_GLD_PRICE * 1.1):
engine = StrategySelectionEngine(
portfolio_value=self.portfolio_value,
loan_amount=self.loan_amount,
margin_call_threshold=self.margin_call_threshold,
spot_price=spot_price,
volatility=DEFAULT_VOLATILITY,
risk_free_rate=DEFAULT_RISK_FREE_RATE,
)
recommendation = engine.recommend("balanced")
results["spot_price"].append(
{
"spot_price": round(spot_price, 2),
"recommended_strategy": recommendation["recommended_strategy"],
}
)
return results

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from dataclasses import dataclass
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.protective_put import DEFAULT_SCENARIO_CHANGES, ProtectivePutSpec, ProtectivePutStrategy
@dataclass(frozen=True)
class LadderSpec:
label: str
weights: tuple[float, ...]
strike_pcts: tuple[float, ...]
months: int = 12
class LadderedPutStrategy(BaseStrategy):
"""Multi-strike protective put ladder with blended premium and protection analysis."""
def __init__(self, config: StrategyConfig, spec: LadderSpec) -> None:
super().__init__(config)
if len(spec.weights) != len(spec.strike_pcts):
raise ValueError("weights and strike_pcts must have the same length")
if abs(sum(spec.weights) - 1.0) > 1e-9:
raise ValueError("weights must sum to 1.0")
self.spec = spec
@property
def name(self) -> str:
return f"laddered_put_{self.spec.label.lower()}"
def _legs(self) -> list[tuple[float, ProtectivePutStrategy]]:
legs: list[tuple[float, ProtectivePutStrategy]] = []
for index, (weight, strike_pct) in enumerate(zip(self.spec.weights, self.spec.strike_pcts, strict=True), start=1):
leg = ProtectivePutStrategy(
self.config,
ProtectivePutSpec(label=f"{self.spec.label}_leg_{index}", strike_pct=strike_pct, months=self.spec.months),
)
legs.append((weight, leg))
return legs
def calculate_cost(self) -> dict:
blended_cost = 0.0
blended_premium = 0.0
legs_summary: list[dict] = []
for weight, leg in self._legs():
contract = leg.build_contract()
weighted_cost = contract.total_premium * weight
blended_cost += weighted_cost
blended_premium += contract.premium * weight
legs_summary.append(
{
"weight": weight,
"strike": round(contract.strike, 2),
"premium_per_share": round(contract.premium, 4),
"weighted_cost": round(weighted_cost, 2),
}
)
annualized_cost = blended_cost / (self.spec.months / 12.0)
return {
"strategy": self.name,
"label": self.spec.label,
"legs": legs_summary,
"blended_premium_per_share": round(blended_premium, 4),
"blended_cost": round(blended_cost, 2),
"cost_pct_of_portfolio": round(blended_cost / self.config.portfolio.gold_value, 6),
"annualized_cost": round(annualized_cost, 2),
"annualized_cost_pct": round(annualized_cost / self.config.portfolio.gold_value, 6),
}
def calculate_protection(self) -> dict:
threshold_price = self.config.portfolio.margin_call_price()
total_payoff = 0.0
floor_value = 0.0
leg_protection: list[dict] = []
for weight, leg in self._legs():
contract = leg.build_contract()
weighted_payoff = contract.payoff(threshold_price) * weight
total_payoff += weighted_payoff
floor_value += contract.strike * leg.hedge_units * weight
leg_protection.append(
{
"weight": weight,
"strike": round(contract.strike, 2),
"weighted_payoff_at_threshold": round(weighted_payoff, 2),
}
)
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + total_payoff
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"portfolio_floor_value": round(floor_value, 2),
"payoff_at_threshold": round(total_payoff, 2),
"unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6),
"hedged_ltv_at_threshold": round(protected_ltv, 6),
"maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv,
"legs": leg_protection,
}
def get_scenarios(self) -> list[dict]:
cost = self.calculate_cost()["blended_cost"]
scenarios: list[dict] = []
for change in DEFAULT_SCENARIO_CHANGES:
price = self.config.spot_price * (1 + change)
if price <= 0:
continue
gold_value = self.config.portfolio.gold_value_at_price(price)
option_payoff = 0.0
for weight, leg in self._legs():
option_payoff += leg.build_contract().payoff(price) * weight
hedged_collateral = gold_value + option_payoff
scenarios.append(
{
"price_change_pct": round(change, 2),
"gld_price": round(price, 2),
"gold_value": round(gold_value, 2),
"option_payoff": round(option_payoff, 2),
"hedge_cost": round(cost, 2),
"net_portfolio_value": round(gold_value + option_payoff - cost, 2),
"unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6),
"hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6),
"margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value)
>= self.config.portfolio.margin_call_ltv,
"margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral)
>= self.config.portfolio.margin_call_ltv,
}
)
return scenarios

95
app/strategies/lease.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
@dataclass(frozen=True)
class LeaseAnalysisSpec:
strike_pct: float = 1.0
durations_months: tuple[int, ...] = (3, 6, 12, 18, 24)
class LeaseStrategy(BaseStrategy):
"""LEAPS duration analysis with roll timing and annualized cost comparison."""
def __init__(self, config: StrategyConfig, spec: LeaseAnalysisSpec | None = None) -> None:
super().__init__(config)
self.spec = spec or LeaseAnalysisSpec()
@property
def name(self) -> str:
return "lease_duration_analysis"
def _protective_put(self, months: int) -> ProtectivePutStrategy:
return ProtectivePutStrategy(
self.config,
ProtectivePutSpec(label=f"LEAPS_{months}M", strike_pct=self.spec.strike_pct, months=months),
)
def _duration_rows(self) -> list[dict]:
rows: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
cost = strategy.calculate_cost()
rolls_per_year = 12 / months
rows.append(
{
"months": months,
"strike": cost["strike"],
"premium_per_share": cost["premium_per_share"],
"total_cost": cost["total_cost"],
"annualized_cost": cost["annualized_cost"],
"annualized_cost_pct": cost["annualized_cost_pct"],
"rolls_per_year": round(rolls_per_year, 4),
"recommended_roll_month": max(1, months - 1),
}
)
return rows
def calculate_cost(self) -> dict:
rows = self._duration_rows()
optimal = min(rows, key=lambda item: item["annualized_cost"])
return {
"strategy": self.name,
"comparison": rows,
"optimal_duration_months": optimal["months"],
"lowest_annual_cost": optimal["annualized_cost"],
"lowest_annual_cost_pct": optimal["annualized_cost_pct"],
}
def calculate_protection(self) -> dict:
threshold_price = self.config.portfolio.margin_call_price()
rows: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
protection = strategy.calculate_protection()
rows.append(
{
"months": months,
"payoff_at_threshold": protection["payoff_at_threshold"],
"hedged_ltv_at_threshold": protection["hedged_ltv_at_threshold"],
"maintains_margin_call_buffer": protection["maintains_margin_call_buffer"],
}
)
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"durations": rows,
}
def get_scenarios(self) -> list[dict]:
scenarios: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
scenarios.append(
{
"months": months,
"annualized_cost": strategy.calculate_cost()["annualized_cost"],
"annualized_cost_pct": strategy.calculate_cost()["annualized_cost_pct"],
"sample_scenarios": strategy.get_scenarios(),
}
)
return scenarios

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from app.core.pricing.black_scholes import BlackScholesInputs, black_scholes_price_and_greeks
from app.models.option import Greeks, OptionContract
from app.models.strategy import HedgingStrategy
from app.strategies.base import BaseStrategy, StrategyConfig
DEFAULT_SCENARIO_CHANGES = (-0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5)
@dataclass(frozen=True)
class ProtectivePutSpec:
label: str
strike_pct: float
months: int = 12
class ProtectivePutStrategy(BaseStrategy):
"""Single-leg protective put strategy using ATM or configurable OTM strikes."""
def __init__(self, config: StrategyConfig, spec: ProtectivePutSpec) -> None:
super().__init__(config)
self.spec = spec
@property
def name(self) -> str:
return f"protective_put_{self.spec.label.lower()}"
@property
def hedge_units(self) -> float:
return self.config.portfolio.gold_value / self.config.spot_price
@property
def strike(self) -> float:
return self.config.spot_price * self.spec.strike_pct
@property
def term_years(self) -> float:
return self.spec.months / 12.0
def build_contract(self) -> OptionContract:
pricing = black_scholes_price_and_greeks(
BlackScholesInputs(
spot=self.config.spot_price,
strike=self.strike,
time_to_expiry=self.term_years,
risk_free_rate=self.config.risk_free_rate,
volatility=self.config.volatility,
option_type="put",
)
)
return OptionContract(
option_type="put",
strike=self.strike,
expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))),
premium=pricing.price,
quantity=1.0,
contract_size=self.hedge_units,
underlying_price=self.config.spot_price,
greeks=Greeks(
delta=pricing.delta,
gamma=pricing.gamma,
theta=pricing.theta,
vega=pricing.vega,
rho=pricing.rho,
),
)
def build_hedging_strategy(self) -> HedgingStrategy:
return HedgingStrategy(
strategy_type="single_put",
long_contracts=(self.build_contract(),),
description=f"{self.spec.label} protective put",
)
def calculate_cost(self) -> dict:
contract = self.build_contract()
total_cost = contract.total_premium
return {
"strategy": self.name,
"label": self.spec.label,
"strike": round(contract.strike, 2),
"strike_pct": self.spec.strike_pct,
"premium_per_share": round(contract.premium, 4),
"total_cost": round(total_cost, 2),
"cost_pct_of_portfolio": round(total_cost / self.config.portfolio.gold_value, 6),
"term_months": self.spec.months,
"annualized_cost": round(total_cost / self.term_years, 2),
"annualized_cost_pct": round((total_cost / self.term_years) / self.config.portfolio.gold_value, 6),
}
def calculate_protection(self) -> dict:
contract = self.build_contract()
threshold_price = self.config.portfolio.margin_call_price()
payoff_at_threshold = contract.payoff(threshold_price)
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + payoff_at_threshold
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
floor_value = contract.strike * self.hedge_units
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"strike": round(contract.strike, 2),
"portfolio_floor_value": round(floor_value, 2),
"unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6),
"hedged_ltv_at_threshold": round(protected_ltv, 6),
"payoff_at_threshold": round(payoff_at_threshold, 2),
"maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv,
}
def get_scenarios(self) -> list[dict]:
strategy = self.build_hedging_strategy()
scenarios: list[dict] = []
for change in DEFAULT_SCENARIO_CHANGES:
price = self.config.spot_price * (1 + change)
if price <= 0:
continue
gold_value = self.config.portfolio.gold_value_at_price(price)
option_payoff = strategy.gross_payoff(price)
hedged_collateral = gold_value + option_payoff
scenarios.append(
{
"price_change_pct": round(change, 2),
"gld_price": round(price, 2),
"gold_value": round(gold_value, 2),
"option_payoff": round(option_payoff, 2),
"hedge_cost": round(strategy.hedge_cost, 2),
"net_portfolio_value": round(gold_value + option_payoff - strategy.hedge_cost, 2),
"unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6),
"hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6),
"margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value)
>= self.config.portfolio.margin_call_ltv,
"margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral)
>= self.config.portfolio.margin_call_ltv,
}
)
return scenarios