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:
85
tests/conftest.py
Normal file
85
tests/conftest.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from app.models.portfolio import LombardPortfolio
|
||||
from app.strategies.base import StrategyConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_portfolio() -> LombardPortfolio:
|
||||
"""Research-paper baseline portfolio: 1M collateral, 600k loan, 460 spot, 75% LTV trigger."""
|
||||
gold_ounces = 1_000_000.0 / 460.0
|
||||
return LombardPortfolio(
|
||||
gold_ounces=gold_ounces,
|
||||
gold_price_per_ounce=460.0,
|
||||
loan_amount=600_000.0,
|
||||
initial_ltv=0.60,
|
||||
margin_call_ltv=0.75,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_strategy_config(sample_portfolio: LombardPortfolio) -> StrategyConfig:
|
||||
return StrategyConfig(
|
||||
portfolio=sample_portfolio,
|
||||
spot_price=sample_portfolio.gold_price_per_ounce,
|
||||
volatility=0.16,
|
||||
risk_free_rate=0.045,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_option_chain(sample_portfolio: LombardPortfolio) -> dict[str, object]:
|
||||
"""Deterministic mock option chain around a 460 GLD reference price."""
|
||||
spot = sample_portfolio.gold_price_per_ounce
|
||||
return {
|
||||
"symbol": "GLD",
|
||||
"updated_at": datetime(2026, 3, 21, 0, 0).isoformat(),
|
||||
"source": "mock",
|
||||
"calls": [
|
||||
{"strike": round(spot * 1.05, 2), "premium": round(spot * 0.03, 2), "expiry": "2026-06-19"},
|
||||
{"strike": round(spot * 1.10, 2), "premium": round(spot * 0.02, 2), "expiry": "2026-09-18"},
|
||||
],
|
||||
"puts": [
|
||||
{"strike": round(spot * 0.95, 2), "premium": round(spot * 0.028, 2), "expiry": "2026-06-19"},
|
||||
{"strike": round(spot * 0.90, 2), "premium": round(spot * 0.018, 2), "expiry": "2026-09-18"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_yfinance_data(monkeypatch):
|
||||
"""Patch yfinance in the data layer with deterministic historical close data."""
|
||||
# Lazy import here to avoid side effects when the environment lacks Python 3.11's
|
||||
# datetime.UTC symbol used in the data_service module.
|
||||
from app.services import data_service as data_service_module
|
||||
|
||||
history = pd.DataFrame({"Close": [458.0, 460.0]}, index=pd.date_range("2026-03-20", periods=2, freq="D"))
|
||||
|
||||
class FakeTicker:
|
||||
def __init__(self, symbol: str) -> None:
|
||||
self.symbol = symbol
|
||||
|
||||
def history(self, period: str, interval: str):
|
||||
return history.copy()
|
||||
|
||||
class FakeYFinance:
|
||||
Ticker = FakeTicker
|
||||
|
||||
monkeypatch.setattr(data_service_module, "yf", FakeYFinance())
|
||||
return {
|
||||
"symbol": "GLD",
|
||||
"history": history,
|
||||
"last_price": 460.0,
|
||||
"previous_price": 458.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_yfinance(mock_yfinance_data):
|
||||
"""Compatibility alias for tests that request a yfinance fixture name."""
|
||||
return mock_yfinance_data
|
||||
13
tests/test_health.py
Normal file
13
tests/test_health.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_endpoint_returns_ok() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert "environment" in payload
|
||||
22
tests/test_portfolio.py
Normal file
22
tests/test_portfolio.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_ltv_calculation(sample_portfolio) -> None:
|
||||
assert sample_portfolio.current_ltv == pytest.approx(0.60, rel=1e-12)
|
||||
assert sample_portfolio.ltv_at_price(460.0) == pytest.approx(0.60, rel=1e-12)
|
||||
assert sample_portfolio.ltv_at_price(368.0) == pytest.approx(0.75, rel=1e-12)
|
||||
|
||||
|
||||
def test_net_equity_calculation(sample_portfolio) -> None:
|
||||
assert sample_portfolio.net_equity == pytest.approx(400_000.0, rel=1e-12)
|
||||
assert sample_portfolio.net_equity_at_price(420.0) == pytest.approx(
|
||||
sample_portfolio.gold_ounces * 420.0 - 600_000.0,
|
||||
rel=1e-12,
|
||||
)
|
||||
|
||||
|
||||
def test_margin_call_threshold(sample_portfolio) -> None:
|
||||
assert sample_portfolio.margin_call_price() == pytest.approx(368.0, rel=1e-12)
|
||||
assert sample_portfolio.ltv_at_price(sample_portfolio.margin_call_price()) == pytest.approx(0.75, rel=1e-12)
|
||||
67
tests/test_pricing.py
Normal file
67
tests/test_pricing.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import app.core.pricing.black_scholes as black_scholes
|
||||
from app.core.pricing.black_scholes import BlackScholesInputs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params, expected_price",
|
||||
[
|
||||
(
|
||||
BlackScholesInputs(
|
||||
spot=460.0,
|
||||
strike=460.0,
|
||||
time_to_expiry=1.0,
|
||||
risk_free_rate=0.045,
|
||||
volatility=0.16,
|
||||
option_type="put",
|
||||
dividend_yield=0.0,
|
||||
),
|
||||
19.68944358516964,
|
||||
)
|
||||
],
|
||||
)
|
||||
def test_put_price_calculation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
params: BlackScholesInputs,
|
||||
expected_price: float,
|
||||
) -> None:
|
||||
"""European put price from the research-paper ATM example."""
|
||||
monkeypatch.setattr(black_scholes, "ql", None)
|
||||
result = black_scholes.black_scholes_price_and_greeks(params)
|
||||
assert result.price == pytest.approx(expected_price, rel=1e-9)
|
||||
|
||||
|
||||
def test_greeks_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Validate Black-Scholes Greeks against research-paper baseline inputs."""
|
||||
monkeypatch.setattr(black_scholes, "ql", None)
|
||||
result = black_scholes.black_scholes_price_and_greeks(
|
||||
BlackScholesInputs(
|
||||
spot=460.0,
|
||||
strike=460.0,
|
||||
time_to_expiry=1.0,
|
||||
risk_free_rate=0.045,
|
||||
volatility=0.16,
|
||||
option_type="put",
|
||||
dividend_yield=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.delta == pytest.approx(-0.35895628379355216, rel=1e-9)
|
||||
assert result.gamma == pytest.approx(0.005078017547110844, rel=1e-9)
|
||||
assert result.theta == pytest.approx(-5.4372889301396174, rel=1e-9)
|
||||
assert result.vega == pytest.approx(171.92136207498476, rel=1e-9)
|
||||
assert result.rho == pytest.approx(-184.80933413020364, rel=1e-9)
|
||||
|
||||
|
||||
def test_margin_call_price_calculation() -> None:
|
||||
"""Margin-call trigger from research defaults: 460 spot, 1,000,000 collateral, 600,000 loan."""
|
||||
threshold = black_scholes.margin_call_threshold_price(
|
||||
portfolio_value=1_000_000.0,
|
||||
loan_amount=600_000.0,
|
||||
current_price=460.0,
|
||||
margin_call_ltv=0.75,
|
||||
)
|
||||
assert threshold == pytest.approx(368.0, rel=1e-12)
|
||||
94
tests/test_strategies.py
Normal file
94
tests/test_strategies.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import app.core.pricing.black_scholes as black_scholes
|
||||
from app.strategies.base import StrategyConfig
|
||||
from app.strategies.laddered_put import LadderSpec, LadderedPutStrategy
|
||||
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
|
||||
|
||||
|
||||
def _force_analytic_pricing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Use deterministic analytical pricing for stable expected values."""
|
||||
monkeypatch.setattr(black_scholes, "ql", None)
|
||||
|
||||
|
||||
def test_protective_put_costs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_strategy_config: StrategyConfig,
|
||||
) -> None:
|
||||
_force_analytic_pricing(monkeypatch)
|
||||
strategy = ProtectivePutStrategy(sample_strategy_config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12))
|
||||
cost = strategy.calculate_cost()
|
||||
|
||||
assert cost["strategy"] == "protective_put_atm"
|
||||
assert cost["label"] == "ATM"
|
||||
assert cost["strike"] == 460.0
|
||||
assert cost["premium_per_share"] == pytest.approx(19.6894, abs=1e-4)
|
||||
assert cost["total_cost"] == pytest.approx(42803.14, abs=1e-2)
|
||||
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042803, abs=1e-6)
|
||||
assert cost["annualized_cost"] == pytest.approx(42803.14, abs=1e-2)
|
||||
assert cost["annualized_cost_pct"] == pytest.approx(0.042803, abs=1e-6)
|
||||
|
||||
|
||||
def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_analytic_pricing(monkeypatch)
|
||||
strategy = LadderedPutStrategy(
|
||||
sample_strategy_config,
|
||||
LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12),
|
||||
)
|
||||
cost = strategy.calculate_cost()
|
||||
protection = strategy.calculate_protection()
|
||||
|
||||
assert cost["strategy"] == "laddered_put_50_50_atm_otm95"
|
||||
assert len(cost["legs"]) == 2
|
||||
assert cost["legs"][0]["weight"] == 0.5
|
||||
assert cost["legs"][0]["strike"] == 460.0
|
||||
assert cost["legs"][1]["strike"] == 437.0
|
||||
assert cost["blended_cost"] == pytest.approx(34200.72, abs=1e-2)
|
||||
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.034201, abs=1e-6)
|
||||
|
||||
assert protection["portfolio_floor_value"] == pytest.approx(975000.0, rel=1e-12)
|
||||
assert protection["payoff_at_threshold"] == pytest.approx(175000.0, abs=1e-2)
|
||||
assert protection["hedged_ltv_at_threshold"] == pytest.approx(0.615385, rel=1e-6)
|
||||
assert protection["maintains_margin_call_buffer"] is True
|
||||
|
||||
|
||||
def test_scenario_analysis(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_strategy_config: StrategyConfig,
|
||||
) -> None:
|
||||
_force_analytic_pricing(monkeypatch)
|
||||
protective = ProtectivePutStrategy(
|
||||
sample_strategy_config,
|
||||
ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12),
|
||||
)
|
||||
ladder = LadderedPutStrategy(
|
||||
sample_strategy_config,
|
||||
LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12),
|
||||
)
|
||||
|
||||
protective_scenarios = protective.get_scenarios()
|
||||
ladder_scenarios = ladder.get_scenarios()
|
||||
|
||||
assert len(protective_scenarios) == 12
|
||||
assert len(ladder_scenarios) == 12
|
||||
|
||||
first_protective = protective_scenarios[0]
|
||||
assert first_protective["price_change_pct"] == -0.6
|
||||
assert first_protective["gld_price"] == 184.0
|
||||
assert first_protective["option_payoff"] == pytest.approx(600000.0, abs=1e-2)
|
||||
assert first_protective["hedge_cost"] == pytest.approx(42803.14, abs=1e-2)
|
||||
assert first_protective["hedged_ltv"] == pytest.approx(0.6, rel=1e-12)
|
||||
assert first_protective["margin_call_with_hedge"] is False
|
||||
|
||||
first_ladder = ladder_scenarios[0]
|
||||
assert first_ladder["gld_price"] == 184.0
|
||||
assert first_ladder["option_payoff"] == pytest.approx(575000.0, abs=1e-2)
|
||||
assert first_ladder["hedge_cost"] == pytest.approx(34200.72, abs=1e-2)
|
||||
assert first_ladder["hedged_ltv"] == pytest.approx(0.615385, rel=1e-6)
|
||||
|
||||
worst_ladder = ladder_scenarios[-1]
|
||||
assert worst_ladder["gld_price"] == 690.0
|
||||
assert worst_ladder["hedged_ltv"] == pytest.approx(0.4, rel=1e-12)
|
||||
assert worst_ladder["margin_call_with_hedge"] is False
|
||||
Reference in New Issue
Block a user