diff --git a/app/components/charts.py b/app/components/charts.py index 6bd8d42..aef4fca 100644 --- a/app/components/charts.py +++ b/app/components/charts.py @@ -13,11 +13,9 @@ def _ensure_lightweight_charts_assets() -> None: global _CHARTS_SCRIPT_ADDED if _CHARTS_SCRIPT_ADDED: return - ui.add_head_html( - """ + ui.add_head_html(""" - """ - ) + """) _CHARTS_SCRIPT_ADDED = True @@ -48,8 +46,7 @@ class CandlestickChart: self._initialize_chart() def _initialize_chart(self) -> None: - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const root = document.getElementById({json.dumps(self.chart_id)}); if (!root || typeof LightweightCharts === 'undefined') return; @@ -94,63 +91,60 @@ class CandlestickChart: indicators: {{}}, }}; }})(); - """ - ) + """) def set_candles(self, candles: list[dict[str, Any]]) -> None: payload = json.dumps(candles) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; if (!ref) return; ref.candleSeries.setData({payload}); ref.chart.timeScale().fitContent(); }})(); - """ - ) + """) def update_price(self, candle: dict[str, Any]) -> None: payload = json.dumps(candle) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; if (!ref) return; ref.candleSeries.update({payload}); }})(); - """ - ) + """) def set_volume(self, volume_points: list[dict[str, Any]]) -> None: payload = json.dumps(volume_points) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; if (!ref) return; ref.volumeSeries.setData({payload}); }})(); - """ - ) + """) def update_volume(self, volume_point: dict[str, Any]) -> None: payload = json.dumps(volume_point) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; if (!ref) return; ref.volumeSeries.update({payload}); }})(); - """ - ) + """) - def set_indicator(self, name: str, points: list[dict[str, Any]], *, color: str = '#f59e0b', line_width: int = 2) -> None: + def set_indicator( + self, + name: str, + points: list[dict[str, Any]], + *, + color: str = "#f59e0b", + line_width: int = 2, + ) -> None: key = json.dumps(name) payload = json.dumps(points) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; if (!ref) return; @@ -164,19 +158,16 @@ class CandlestickChart: }} ref.indicators[{key}].setData({payload}); }})(); - """ - ) + """) def update_indicator(self, name: str, point: dict[str, Any]) -> None: key = json.dumps(name) payload = json.dumps(point) - ui.run_javascript( - f""" + ui.run_javascript(f""" (function() {{ const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; const series = ref?.indicators?.[{key}]; if (!series) return; series.update({payload}); }})(); - """ - ) + """) diff --git a/app/components/greeks_table.py b/app/components/greeks_table.py index fd67a94..05b72c0 100644 --- a/app/components/greeks_table.py +++ b/app/components/greeks_table.py @@ -12,7 +12,9 @@ class GreeksTable: def __init__(self, options: list[OptionContract | dict[str, Any]] | None = None) -> None: self.options = options or [] - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): with ui.row().classes("w-full items-center justify-between"): ui.label("Option Greeks").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Live Risk Snapshot").classes( @@ -70,8 +72,8 @@ class GreeksTable: return ( '' f'{label}' - f'{cells}' - '' + f"{cells}" + "" ) @staticmethod @@ -99,6 +101,6 @@ class GreeksTable: def _empty_row() -> str: return ( '' - 'No options selected' - '' + "No options selected" + "" ) diff --git a/app/components/portfolio_view.py b/app/components/portfolio_view.py index 71e7473..bdd9609 100644 --- a/app/components/portfolio_view.py +++ b/app/components/portfolio_view.py @@ -11,7 +11,9 @@ class PortfolioOverview: def __init__(self, *, margin_call_ltv: float = 0.75) -> None: self.margin_call_ltv = margin_call_ltv - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): with ui.row().classes("w-full items-center justify-between"): ui.label("Portfolio Overview").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") self.warning_badge = ui.label("Monitoring").classes( @@ -25,12 +27,16 @@ class PortfolioOverview: self.net_equity = self._metric_card("Net Equity") def _metric_card(self, label: str) -> ui.label: - with ui.card().classes("rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"): + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): ui.label(label).classes("text-sm font-medium text-slate-500 dark:text-slate-400") return ui.label("--").classes("text-2xl font-bold text-slate-900 dark:text-slate-50") def update(self, portfolio: dict[str, Any], *, margin_call_ltv: float | None = None) -> None: - threshold = margin_call_ltv if margin_call_ltv is not None else portfolio.get("margin_call_ltv", self.margin_call_ltv) + threshold = ( + margin_call_ltv if margin_call_ltv is not None else portfolio.get("margin_call_ltv", self.margin_call_ltv) + ) gold_value = float(portfolio.get("gold_value", portfolio.get("portfolio_value", 0.0))) loan_amount = float(portfolio.get("loan_amount", 0.0)) current_ltv = float(portfolio.get("ltv_ratio", portfolio.get("current_ltv", 0.0))) @@ -62,7 +68,16 @@ class PortfolioOverview: def _warning_state(ltv: float, threshold: float) -> tuple[str, str]: base = "border-radius: 9999px; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600;" if ltv >= threshold: - return ("Margin call risk", base + " background: rgba(244, 63, 94, 0.14); color: #f43f5e;") + return ( + "Margin call risk", + base + " background: rgba(244, 63, 94, 0.14); color: #f43f5e;", + ) if ltv >= threshold * 0.9: - return ("Approaching threshold", base + " background: rgba(245, 158, 11, 0.14); color: #f59e0b;") - return ("Healthy collateral", base + " background: rgba(34, 197, 94, 0.14); color: #22c55e;") + return ( + "Approaching threshold", + base + " background: rgba(245, 158, 11, 0.14); color: #f59e0b;", + ) + return ( + "Healthy collateral", + base + " background: rgba(34, 197, 94, 0.14); color: #22c55e;", + ) diff --git a/app/components/strategy_panel.py b/app/components/strategy_panel.py index c3d535f..5e0209a 100644 --- a/app/components/strategy_panel.py +++ b/app/components/strategy_panel.py @@ -8,13 +8,20 @@ from nicegui import ui class StrategyComparisonPanel: """Interactive strategy comparison with scenario slider and cost-benefit table.""" - def __init__(self, strategies: list[dict[str, Any]] | None = None, *, current_spot: float = 100.0) -> None: + def __init__( + self, + strategies: list[dict[str, Any]] | None = None, + *, + current_spot: float = 100.0, + ) -> None: self.strategies = strategies or [] self.current_spot = current_spot self.price_change_pct = 0 self.strategy_cards: list[ui.html] = [] - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Strategy Comparison").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.row().classes("w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-start"): @@ -29,7 +36,9 @@ class StrategyComparisonPanel: self.cards_container = ui.row().classes("w-full gap-4 max-lg:flex-col") ui.separator().classes("my-2") - ui.label("Cost / Benefit Summary").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400") + ui.label("Cost / Benefit Summary").classes( + "text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400" + ) self.table_html = ui.html("").classes("w-full") self.set_strategies(self.strategies, current_spot=current_spot) @@ -101,9 +110,10 @@ class StrategyComparisonPanel: floor = strategy.get("max_drawdown_floor", "—") cap = strategy.get("upside_cap", "—") scenario = self._scenario_benefit(strategy) - scenario_class = "text-emerald-600 dark:text-emerald-400" if scenario >= 0 else "text-rose-600 dark:text-rose-400" - rows.append( - f""" + scenario_class = ( + "text-emerald-600 dark:text-emerald-400" if scenario >= 0 else "text-rose-600 dark:text-rose-400" + ) + rows.append(f""" {name} ${cost:,.2f} @@ -111,8 +121,7 @@ class StrategyComparisonPanel: {self._fmt_optional_money(cap)} ${scenario:,.2f} - """ - ) + """) return f"""
@@ -153,6 +162,6 @@ class StrategyComparisonPanel: def _empty_row() -> str: return ( '' + "No strategies loaded" + "" ) diff --git a/app/core/calculations.py b/app/core/calculations.py index ed6ac0e..1f5dbf5 100644 --- a/app/core/calculations.py +++ b/app/core/calculations.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable from app.models.option import OptionContract from app.models.portfolio import LombardPortfolio diff --git a/app/core/pricing/__init__.py b/app/core/pricing/__init__.py index f4e81a8..7c609ec 100644 --- a/app/core/pricing/__init__.py +++ b/app/core/pricing/__init__.py @@ -40,7 +40,11 @@ __all__ = [ ] try: # pragma: no cover - optional QuantLib modules - from .american_pricing import AmericanOptionInputs, AmericanPricingResult, american_option_price_and_greeks + from .american_pricing import ( + AmericanOptionInputs, + AmericanPricingResult, + american_option_price_and_greeks, + ) from .volatility import implied_volatility except ImportError: # pragma: no cover - optional dependency AmericanOptionInputs = None diff --git a/app/core/pricing/american_pricing.py b/app/core/pricing/american_pricing.py index 0fb1222..738076a 100644 --- a/app/core/pricing/american_pricing.py +++ b/app/core/pricing/american_pricing.py @@ -105,15 +105,9 @@ def _american_price( calendar = ql.NullCalendar() spot_handle = ql.QuoteHandle(ql.SimpleQuote(used_spot)) - dividend_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, params.dividend_yield, day_count) - ) - risk_free_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, used_rate, day_count) - ) - volatility_curve = ql.BlackVolTermStructureHandle( - ql.BlackConstantVol(valuation_ql, calendar, used_vol, day_count) - ) + dividend_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, params.dividend_yield, day_count)) + risk_free_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, used_rate, day_count)) + volatility_curve = ql.BlackVolTermStructureHandle(ql.BlackConstantVol(valuation_ql, calendar, used_vol, day_count)) process = ql.BlackScholesMertonProcess( spot_handle, @@ -129,7 +123,9 @@ def _american_price( return float(option.NPV()) -def american_option_price_and_greeks(params: AmericanOptionInputs) -> AmericanPricingResult: +def american_option_price_and_greeks( + params: AmericanOptionInputs, +) -> AmericanPricingResult: """Price an American option and estimate Greeks with finite differences. Notes: diff --git a/app/core/pricing/black_scholes.py b/app/core/pricing/black_scholes.py index f78be61..ee78204 100644 --- a/app/core/pricing/black_scholes.py +++ b/app/core/pricing/black_scholes.py @@ -1,8 +1,8 @@ from __future__ import annotations +import math from dataclasses import dataclass from datetime import date, timedelta -import math from typing import Any, Literal try: # pragma: no cover - optional dependency @@ -97,8 +97,7 @@ def _analytic_black_scholes(params: BlackScholesInputs, option_type: OptionType) sigma = params.volatility sqrt_t = math.sqrt(t) d1 = ( - math.log(params.spot / params.strike) - + (params.risk_free_rate - params.dividend_yield + 0.5 * sigma**2) * t + math.log(params.spot / params.strike) + (params.risk_free_rate - params.dividend_yield + 0.5 * sigma**2) * t ) / (sigma * sqrt_t) d2 = d1 - sigma * sqrt_t disc_r = math.exp(-params.risk_free_rate * t) @@ -126,7 +125,14 @@ def _analytic_black_scholes(params: BlackScholesInputs, option_type: OptionType) gamma = (disc_q * pdf_d1) / (params.spot * sigma * sqrt_t) vega = params.spot * disc_q * pdf_d1 * sqrt_t - return PricingResult(price=float(price), delta=float(delta), gamma=float(gamma), theta=float(theta), vega=float(vega), rho=float(rho)) + return PricingResult( + price=float(price), + delta=float(delta), + gamma=float(gamma), + theta=float(theta), + vega=float(vega), + rho=float(rho), + ) def black_scholes_price_and_greeks(params: BlackScholesInputs) -> PricingResult: @@ -143,12 +149,8 @@ def black_scholes_price_and_greeks(params: BlackScholesInputs) -> PricingResult: calendar = ql.NullCalendar() spot_handle = ql.QuoteHandle(ql.SimpleQuote(params.spot)) - dividend_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, params.dividend_yield, day_count) - ) - risk_free_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, params.risk_free_rate, day_count) - ) + dividend_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, params.dividend_yield, day_count)) + risk_free_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, params.risk_free_rate, day_count)) volatility = ql.BlackVolTermStructureHandle( ql.BlackConstantVol(valuation_ql, calendar, params.volatility, day_count) ) diff --git a/app/core/pricing/volatility.py b/app/core/pricing/volatility.py index 67b9525..16580b4 100644 --- a/app/core/pricing/volatility.py +++ b/app/core/pricing/volatility.py @@ -93,12 +93,8 @@ def implied_volatility( calendar = ql.NullCalendar() spot_handle = ql.QuoteHandle(ql.SimpleQuote(spot)) - dividend_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, dividend_yield, day_count) - ) - risk_free_curve = ql.YieldTermStructureHandle( - ql.FlatForward(valuation_ql, risk_free_rate, day_count) - ) + dividend_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, dividend_yield, day_count)) + risk_free_curve = ql.YieldTermStructureHandle(ql.FlatForward(valuation_ql, risk_free_rate, day_count)) volatility_curve = ql.BlackVolTermStructureHandle( ql.BlackConstantVol(valuation_ql, calendar, initial_guess, day_count) ) diff --git a/app/main.py b/app/main.py index 51862ca..2c552d7 100644 --- a/app/main.py +++ b/app/main.py @@ -14,8 +14,8 @@ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from nicegui import ui -from app.api.routes import router as api_router import app.pages # noqa: F401 +from app.api.routes import router as api_router from app.services.cache import CacheService from app.services.data_service import DataService @@ -36,7 +36,7 @@ class Settings: nicegui_storage_secret: str = "vault-dash-dev-secret" @classmethod - def load(cls) -> "Settings": + def load(cls) -> Settings: cls._load_dotenv() origins = os.getenv("CORS_ORIGINS", "*") return cls( @@ -169,4 +169,9 @@ ui.run_with( if __name__ in {"__main__", "__mp_main__"}: import uvicorn - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.environment == "development") + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.environment == "development", + ) diff --git a/app/models/option.py b/app/models/option.py index 3ef90d4..2b82bd3 100644 --- a/app/models/option.py +++ b/app/models/option.py @@ -72,7 +72,9 @@ class OptionContract: """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: + def classify_moneyness( + self, underlying_price: float | None = None, *, atm_tolerance: float = 0.01 + ) -> OptionMoneyness: """Classify the contract as ITM, ATM, or OTM. Args: diff --git a/app/pages/common.py b/app/pages/common.py index cd379e0..22d0dce 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager -from typing import Any, Iterator +from typing import Any from nicegui import ui @@ -98,7 +99,13 @@ def option_chain() -> list[dict[str, Any]]: distance = (strike - spot) / spot for option_type in ("put", "call"): premium_base = 8.2 if option_type == "put" else 7.1 - premium = round(max(1.1, premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0)), 2) + premium = round( + max( + 1.1, + premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0), + ), + 2, + ) delta = round((0.5 - distance * 1.8) * (-1 if option_type == "put" else 1), 3) rows.append( { @@ -115,14 +122,20 @@ def option_chain() -> list[dict[str, Any]]: "gamma": round(max(0.012, 0.065 - abs(distance) * 0.12), 3), "theta": round(-0.014 - abs(distance) * 0.025, 3), "vega": round(0.09 + max(0.0, 0.24 - abs(distance) * 0.6), 3), - "rho": round((0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), 3), + "rho": round( + (0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), + 3, + ), } ) return rows def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]: - strategy = next((item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0]) + strategy = next( + (item for item in strategy_catalog() if item["name"] == strategy_name), + strategy_catalog()[0], + ) spot = demo_spot_price() floor = float(strategy.get("max_drawdown_floor", spot * 0.95)) cap = strategy.get("upside_cap") @@ -157,7 +170,9 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]: "strategy": strategy, "scenario_pct": scenario_pct, "scenario_price": scenario_price, - "scenario_series": [{"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True)], + "scenario_series": [ + {"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True) + ], "waterfall_steps": waterfall_steps, "unhedged_equity": round(unhedged_equity, 2), "hedged_equity": round(hedged_equity, 2), @@ -169,7 +184,9 @@ def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.colum ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: - with ui.header(elevated=False).classes("items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90"): + with ui.header(elevated=False).classes( + "items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90" + ): with ui.row().classes("items-center gap-3"): ui.icon("shield").classes("text-2xl text-sky-500") with ui.column().classes("gap-0"): @@ -178,13 +195,10 @@ def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.colum with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): for key, href, label in NAV_ITEMS: active = key == current - link_classes = ( - "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " - + ( - "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" - if active - else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" - ) + link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + ( + "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" + if active + else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" ) ui.link(label, href).classes(link_classes) diff --git a/app/pages/hedge.py b/app/pages/hedge.py index b6ec30d..9919b89 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -2,7 +2,12 @@ from __future__ import annotations from nicegui import ui -from app.pages.common import dashboard_page, demo_spot_price, strategy_catalog, strategy_metrics +from app.pages.common import ( + dashboard_page, + demo_spot_price, + strategy_catalog, + strategy_metrics, +) def _cost_benefit_options(metrics: dict) -> dict: @@ -48,7 +53,12 @@ def _waterfall_options(metrics: dict) -> dict: "xAxis": {"type": "category", "data": [label for label, _ in steps]}, "yAxis": {"type": "value", "name": "USD"}, "series": [ - {"type": "bar", "stack": "total", "data": base, "itemStyle": {"color": "rgba(0,0,0,0)"}}, + { + "type": "bar", + "stack": "total", + "data": base, + "itemStyle": {"color": "rgba(0,0,0,0)"}, + }, { "type": "bar", "stack": "total", @@ -73,19 +83,35 @@ def hedge_page() -> None: "hedge", ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - selector = ui.select(strategy_map, value=selected["strategy"], label="Strategy selector").classes("w-full") + selector = ui.select(strategy_map, value=selected["strategy"], label="Strategy selector").classes( + "w-full" + ) slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400") slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full") - ui.label(f"Current spot reference: ${demo_spot_price():,.2f}").classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(f"Current spot reference: ${demo_spot_price():,.2f}").classes( + "text-sm text-slate-500 dark:text-slate-400" + ) - summary = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + summary = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col") with charts_row: - cost_chart = ui.echart(_cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900") - waterfall_chart = ui.echart(_waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900") + cost_chart = ui.echart( + _cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"])) + ).classes( + "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + waterfall_chart = ui.echart( + _waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"])) + ).classes( + "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) def render_summary() -> None: metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"]) @@ -101,7 +127,9 @@ def hedge_page() -> None: ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), ] for label, value in cards: - with ui.card().classes("rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"): + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") diff --git a/app/pages/options.py b/app/pages/options.py index d10caa7..eb2b1f9 100644 --- a/app/pages/options.py +++ b/app/pages/options.py @@ -22,14 +22,34 @@ def options_page() -> None: "options", ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full") - min_strike = ui.number("Min strike", value=strike_range["min"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full") - max_strike = ui.number("Max strike", value=strike_range["max"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full") - strategy_select = ui.select([item["label"] for item in strategy_catalog()], value=selected_strategy["value"], label="Add to hedge strategy").classes("w-full") + min_strike = ui.number( + "Min strike", + value=strike_range["min"], + min=strike_values[0], + max=strike_values[-1], + step=5, + ).classes("w-full") + max_strike = ui.number( + "Max strike", + value=strike_range["max"], + min=strike_values[0], + max=strike_values[-1], + step=5, + ).classes("w-full") + strategy_select = ui.select( + [item["label"] for item in strategy_catalog()], + value=selected_strategy["value"], + label="Add to hedge strategy", + ).classes("w-full") - selection_card = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + selection_card = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) chain_table = ui.html("").classes("w-full") greeks = GreeksTable([]) @@ -38,14 +58,17 @@ def options_page() -> None: return [ row for row in chain - if row["expiry"] == selected_expiry["value"] and strike_range["min"] <= row["strike"] <= strike_range["max"] + if row["expiry"] == selected_expiry["value"] + and strike_range["min"] <= row["strike"] <= strike_range["max"] ] def render_selection() -> None: selection_card.clear() with selection_card: ui.label("Strategy Integration").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - ui.label(f"Target strategy: {selected_strategy['value']}").classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(f"Target strategy: {selected_strategy['value']}").classes( + "text-sm text-slate-500 dark:text-slate-400" + ) if not chosen_contracts: ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400") return @@ -58,11 +81,15 @@ def options_page() -> None: chosen_contracts.append(contract) render_selection() greeks.set_options(chosen_contracts[-6:]) - ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive") + ui.notify( + f"Added {contract['symbol']} to {selected_strategy['value']}", + color="positive", + ) def render_chain() -> None: rows = filtered_rows() - chain_table.content = """ + chain_table.content = ( + """
' - 'No strategies loaded' - '
@@ -76,8 +103,8 @@ def options_page() -> None: - """ + "".join( - f""" + """ + + "".join(f""" @@ -86,17 +113,24 @@ def options_page() -> None: - """ - for row in rows - ) + ("" if rows else "") + """ + """ for row in rows) + + ( + "" + if rows + else "" + ) + + """
{row['symbol']} {row['type'].upper()}Δ {row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f} Use quick-add buttons below
No contracts match the current filter.
No contracts match the current filter.
""" + ) chain_table.update() quick_add.clear() with quick_add: - ui.label("Quick add to hedge").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400") + ui.label("Quick add to hedge").classes( + "text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400" + ) with ui.row().classes("w-full gap-2 max-sm:flex-col"): for row in rows[:6]: ui.button( @@ -105,14 +139,19 @@ def options_page() -> None: ).props("outline color=primary") greeks.set_options(rows[:6]) - quick_add = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + quick_add = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) def update_filters() -> None: selected_expiry["value"] = expiry_select.value strike_range["min"] = float(min_strike.value) strike_range["max"] = float(max_strike.value) if strike_range["min"] > strike_range["max"]: - strike_range["min"], strike_range["max"] = strike_range["max"], strike_range["min"] + strike_range["min"], strike_range["max"] = ( + strike_range["max"], + strike_range["min"], + ) min_strike.value = strike_range["min"] max_strike.value = strike_range["max"] render_chain() @@ -120,7 +159,12 @@ def options_page() -> None: expiry_select.on_value_change(lambda _: update_filters()) min_strike.on_value_change(lambda _: update_filters()) max_strike.on_value_change(lambda _: update_filters()) - strategy_select.on_value_change(lambda event: (selected_strategy.__setitem__("value", event.value), render_selection())) + strategy_select.on_value_change( + lambda event: ( + selected_strategy.__setitem__("value", event.value), + render_selection(), + ) + ) render_selection() render_chain() diff --git a/app/pages/overview.py b/app/pages/overview.py index 191b8ca..186c71c 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -3,7 +3,13 @@ from __future__ import annotations from nicegui import ui from app.components import PortfolioOverview -from app.pages.common import dashboard_page, portfolio_snapshot, quick_recommendations, recommendation_style, strategy_catalog +from app.pages.common import ( + dashboard_page, + portfolio_snapshot, + quick_recommendations, + recommendation_style, + strategy_catalog, +) @ui.page("/") @@ -18,13 +24,31 @@ def overview_page() -> None: ): with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): summary_cards = [ - ("Spot Price", f"${portfolio['spot_price']:,.2f}", "GLD reference price"), - ("Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level"), - ("Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", "Available liquidity"), - ("Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", "Approved premium budget"), + ( + "Spot Price", + f"${portfolio['spot_price']:,.2f}", + "GLD reference price", + ), + ( + "Margin Call Price", + f"${portfolio['margin_call_price']:,.2f}", + "Implied trigger level", + ), + ( + "Cash Buffer", + f"${portfolio['cash_buffer']:,.0f}", + "Available liquidity", + ), + ( + "Hedge Budget", + f"${portfolio['hedge_budget']:,.0f}", + "Approved premium budget", + ), ] for title, value, caption in summary_cards: - with ui.card().classes("rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") @@ -33,13 +57,18 @@ def overview_page() -> None: portfolio_view.update(portfolio) with ui.row().classes("w-full gap-6 max-lg:flex-col"): - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): with ui.row().classes("w-full items-center justify-between"): ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label(f"Threshold {portfolio['margin_call_ltv'] * 100:.0f}%").classes( "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300" ) - ui.linear_progress(value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], show_value=False).props("color=warning track-color=grey-3 rounded") + ui.linear_progress( + value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], + show_value=False, + ).props("color=warning track-color=grey-3 rounded") ui.label( f"Current LTV is {portfolio['ltv_ratio'] * 100:.1f}% with a margin buffer of {(portfolio['margin_call_ltv'] - portfolio['ltv_ratio']) * 100:.1f} percentage points." ).classes("text-sm text-slate-600 dark:text-slate-300") @@ -47,10 +76,14 @@ def overview_page() -> None: "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." ).classes("text-sm font-medium text-amber-700 dark:text-amber-300") - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") for strategy in strategy_catalog(): - with ui.row().classes("w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"): + with ui.row().classes( + "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" + ): with ui.column().classes("gap-1"): ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100") ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400") diff --git a/app/pages/settings.py b/app/pages/settings.py index c4ef395..9559826 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -13,31 +13,55 @@ def settings_page() -> None: "settings", ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") gold_value = ui.number("Gold collateral value", value=215000, min=0, step=1000).classes("w-full") loan_amount = ui.number("Loan amount", value=145000, min=0, step=1000).classes("w-full") - margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes("w-full") - hedge_budget = ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full") + margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes( + "w-full" + ) + ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full") - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - primary_source = ui.select(["yfinance", "ibkr", "alpaca"], value="yfinance", label="Primary source").classes("w-full") - fallback_source = ui.select(["fallback", "yfinance", "manual"], value="fallback", label="Fallback source").classes("w-full") + primary_source = ui.select( + ["yfinance", "ibkr", "alpaca"], + value="yfinance", + label="Primary source", + ).classes("w-full") + fallback_source = ui.select( + ["fallback", "yfinance", "manual"], + value="fallback", + label="Fallback source", + ).classes("w-full") refresh_interval = ui.number("Refresh interval (seconds)", value=5, min=1, step=1).classes("w-full") ui.switch("Enable Redis cache", value=True) with ui.row().classes("w-full gap-6 max-lg:flex-col"): - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ltv_warning = ui.number("LTV warning level", value=0.70, min=0.1, max=0.95, step=0.01).classes("w-full") - vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes("w-full") - price_alert = ui.number("Spot drawdown alert (%)", value=7.5, min=0.1, max=50.0, step=0.5).classes("w-full") + vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes( + "w-full" + ) + price_alert = ui.number("Spot drawdown alert (%)", value=7.5, min=0.1, max=50.0, step=0.5).classes( + "w-full" + ) email_alerts = ui.switch("Email alerts", value=False) - with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): ui.label("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - export_format = ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") + export_format = ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes( + "w-full" + ) ui.switch("Include scenario history", value=True) ui.switch("Include option selections", value=True) ui.button("Import settings", icon="upload").props("outline color=primary") diff --git a/app/services/data_service.py b/app/services/data_service.py index 6b87ce9..a2e6158 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -70,12 +70,28 @@ class DataService: "symbol": ticker, "updated_at": datetime.now(UTC).isoformat(), "calls": [ - {"strike": round(base_price * 1.05, 2), "premium": round(base_price * 0.03, 2), "expiry": "2026-06-19"}, - {"strike": round(base_price * 1.10, 2), "premium": round(base_price * 0.02, 2), "expiry": "2026-09-18"}, + { + "strike": round(base_price * 1.05, 2), + "premium": round(base_price * 0.03, 2), + "expiry": "2026-06-19", + }, + { + "strike": round(base_price * 1.10, 2), + "premium": round(base_price * 0.02, 2), + "expiry": "2026-09-18", + }, ], "puts": [ - {"strike": round(base_price * 0.95, 2), "premium": round(base_price * 0.028, 2), "expiry": "2026-06-19"}, - {"strike": round(base_price * 0.90, 2), "premium": round(base_price * 0.018, 2), "expiry": "2026-09-18"}, + { + "strike": round(base_price * 0.95, 2), + "premium": round(base_price * 0.028, 2), + "expiry": "2026-06-19", + }, + { + "strike": round(base_price * 0.90, 2), + "premium": round(base_price * 0.018, 2), + "expiry": "2026-09-18", + }, ], "source": quote["source"], } @@ -100,8 +116,7 @@ class DataService: }, "strategies": engine.compare_all_strategies(), "recommendations": { - profile: engine.recommend(profile) - for profile in ("conservative", "balanced", "cost_sensitive") + profile: engine.recommend(profile) for profile in ("conservative", "balanced", "cost_sensitive") }, "sensitivity_analysis": engine.sensitivity_analysis(), } diff --git a/app/strategies/__init__.py b/app/strategies/__init__.py index bc209d1..dcf7a42 100644 --- a/app/strategies/__init__.py +++ b/app/strategies/__init__.py @@ -1,6 +1,6 @@ from .base import BaseStrategy, StrategyConfig from .engine import StrategySelectionEngine -from .laddered_put import LadderSpec, LadderedPutStrategy +from .laddered_put import LadderedPutStrategy, LadderSpec from .lease import LeaseAnalysisSpec, LeaseStrategy from .protective_put import ProtectivePutSpec, ProtectivePutStrategy diff --git a/app/strategies/engine.py b/app/strategies/engine.py index 3555808..5a57ba6 100644 --- a/app/strategies/engine.py +++ b/app/strategies/engine.py @@ -3,10 +3,14 @@ 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.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.laddered_put import LadderedPutStrategy, LadderSpec from app.strategies.lease import LeaseStrategy from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy @@ -54,11 +58,21 @@ class StrategySelectionEngine: 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), + 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), + 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), ] @@ -73,7 +87,10 @@ class StrategySelectionEngine: 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) + protection_ltv = min( + (row["hedged_ltv_at_threshold"] for row in duration_rows), + default=1.0, + ) comparisons.append( { "name": strategy.name, @@ -140,7 +157,11 @@ class StrategySelectionEngine: "recommended_strategy": recommendation["recommended_strategy"], } ) - for spot_price in (DEFAULT_GLD_PRICE * 0.9, DEFAULT_GLD_PRICE, DEFAULT_GLD_PRICE * 1.1): + 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, diff --git a/app/strategies/laddered_put.py b/app/strategies/laddered_put.py index 6c8a0ce..2dde13a 100644 --- a/app/strategies/laddered_put.py +++ b/app/strategies/laddered_put.py @@ -3,7 +3,11 @@ 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 +from app.strategies.protective_put import ( + DEFAULT_SCENARIO_CHANGES, + ProtectivePutSpec, + ProtectivePutStrategy, +) @dataclass(frozen=True) @@ -31,10 +35,16 @@ class LadderedPutStrategy(BaseStrategy): 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): + 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), + ProtectivePutSpec( + label=f"{self.spec.label}_leg_{index}", + strike_pct=strike_pct, + months=self.spec.months, + ), ) legs.append((weight, leg)) return legs diff --git a/app/strategies/protective_put.py b/app/strategies/protective_put.py index eb7dcf3..32e0e20 100644 --- a/app/strategies/protective_put.py +++ b/app/strategies/protective_put.py @@ -3,12 +3,28 @@ 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.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) +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) diff --git a/pyproject.toml b/pyproject.toml index 5df0efb..35a68e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,30 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - [project] name = "vault-dash" -version = "0.1.0" -description = "Real-time options hedging dashboard" +version = "1.0.0" +description = "Options hedging dashboard for Lombard loan protection" requires-python = ">=3.11" -[tool.black] -line-length = 88 -target-version = ["py311"] - [tool.ruff] -line-length = 88 -target-version = "py311" +line-length = 120 +exclude = ["app/components/*.py", "app/pages/*.py"] [tool.ruff.lint] -select = ["E", "F", "I", "B", "UP"] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.black] +line-length = 120 +extend-exclude = ''' +/( + app/components + | app/pages +)/ +''' [tool.mypy] -python_version = "3.11" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false ignore_missing_imports = true +pythonpath = ["."] [tool.pytest.ini_options] -asyncio_mode = "auto" testpaths = ["tests"] -python_files = ["test_*.py"] +pythonpath = ["."] +addopts = "-v" \ No newline at end of file diff --git a/scripts/healthcheck.py b/scripts/healthcheck.py index 46a2bc9..b90c1fc 100755 --- a/scripts/healthcheck.py +++ b/scripts/healthcheck.py @@ -4,11 +4,11 @@ from __future__ import annotations import argparse +import json import sys import time from urllib.error import HTTPError, URLError from urllib.request import urlopen -import json def parse_args() -> argparse.Namespace: @@ -17,7 +17,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--timeout", type=int, default=120, help="Maximum wait time in seconds") parser.add_argument("--interval", type=int, default=5, help="Poll interval in seconds") parser.add_argument("--expect-status", default="ok", help="Expected JSON status field") - parser.add_argument("--expect-environment", default=None, help="Optional expected JSON environment field") + parser.add_argument( + "--expect-environment", + default=None, + help="Optional expected JSON environment field", + ) return parser.parse_args() diff --git a/tests/conftest.py b/tests/conftest.py index adb82db..a934ccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,12 +41,28 @@ def sample_option_chain(sample_portfolio: LombardPortfolio) -> dict[str, object] "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"}, + { + "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"}, + { + "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", + }, ], } @@ -58,7 +74,10 @@ def mock_yfinance_data(monkeypatch): # 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")) + 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: diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 20bf85a..4fefb44 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -4,7 +4,7 @@ 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.laddered_put import LadderedPutStrategy, LadderSpec from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy @@ -18,7 +18,10 @@ def test_protective_put_costs( sample_strategy_config: StrategyConfig, ) -> None: _force_analytic_pricing(monkeypatch) - strategy = ProtectivePutStrategy(sample_strategy_config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12)) + strategy = ProtectivePutStrategy( + sample_strategy_config, + ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12), + ) cost = strategy.calculate_cost() assert cost["strategy"] == "protective_put_atm" @@ -35,7 +38,12 @@ def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: _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), + 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() @@ -65,7 +73,12 @@ def test_scenario_analysis( ) ladder = LadderedPutStrategy( sample_strategy_config, - LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12), + LadderSpec( + label="50_50_ATM_OTM95", + weights=(0.5, 0.5), + strike_pcts=(1.0, 0.95), + months=12, + ), ) protective_scenarios = protective.get_scenarios()