4 Commits

Author SHA1 Message Date
Bu5hm4nn
70ec625146 feat(DATA-002): add live GLD options chain data via yfinance 2026-03-23 22:53:08 +01:00
Bu5hm4nn
c14ff83adc Merge PORT-001: Portfolio configuration persistence 2026-03-23 22:27:28 +01:00
Bu5hm4nn
77456c0cb4 Merge DATA-001: Live price feed integration 2026-03-23 22:27:28 +01:00
Bu5hm4nn
80a8ffae0c feat(PORT-001): Add persistent portfolio configuration with validation
- Create PortfolioConfig dataclass with validation
- Add PortfolioRepository for file-based persistence
- Update settings page with live LTV calculations
- Add real-time calculated displays (LTV, margin buffer, margin call price)
2026-03-23 22:27:09 +01:00
6 changed files with 498 additions and 105 deletions

View File

@@ -18,6 +18,7 @@ import app.pages # noqa: F401
from app.api.routes import router as api_router from app.api.routes import router as api_router
from app.services.cache import CacheService from app.services.cache import CacheService
from app.services.data_service import DataService from app.services.data_service import DataService
from app.services.runtime import set_data_service
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -110,6 +111,7 @@ async def lifespan(app: FastAPI):
app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl) app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl)
await app.state.cache.connect() await app.state.cache.connect()
app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol) app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol)
set_data_service(app.state.data_service)
app.state.ws_manager = ConnectionManager() app.state.ws_manager = ConnectionManager()
app.state.publisher_task = asyncio.create_task(publish_updates(app)) app.state.publisher_task = asyncio.create_task(publish_updates(app))
logger.info("Application startup complete") logger.info("Application startup complete")

View File

@@ -1,19 +1,16 @@
"""Portfolio configuration and domain portfolio models."""
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True) @dataclass(frozen=True)
class LombardPortfolio: class LombardPortfolio:
"""Lombard loan portfolio backed by physical gold. """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_ounces: float
gold_price_per_ounce: float gold_price_per_ounce: float
@@ -39,33 +36,167 @@ class LombardPortfolio:
@property @property
def gold_value(self) -> float: def gold_value(self) -> float:
"""Current market value of pledged gold."""
return self.gold_ounces * self.gold_price_per_ounce return self.gold_ounces * self.gold_price_per_ounce
@property @property
def current_ltv(self) -> float: def current_ltv(self) -> float:
"""Current loan-to-value ratio."""
return self.loan_amount / self.gold_value return self.loan_amount / self.gold_value
@property @property
def net_equity(self) -> float: def net_equity(self) -> float:
"""Equity remaining after subtracting the loan from gold value."""
return self.gold_value - self.loan_amount return self.gold_value - self.loan_amount
def gold_value_at_price(self, gold_price_per_ounce: float) -> float: 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: if gold_price_per_ounce <= 0:
raise ValueError("gold_price_per_ounce must be positive") raise ValueError("gold_price_per_ounce must be positive")
return self.gold_ounces * gold_price_per_ounce return self.gold_ounces * gold_price_per_ounce
def ltv_at_price(self, gold_price_per_ounce: float) -> float: 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) 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: 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 return self.gold_value_at_price(gold_price_per_ounce) - self.loan_amount
def margin_call_price(self) -> float: 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) return self.loan_amount / (self.margin_call_ltv * self.gold_ounces)
@dataclass
class PortfolioConfig:
"""User portfolio configuration with validation.
Attributes:
gold_value: Current gold collateral value in USD
loan_amount: Outstanding loan amount in USD
margin_threshold: LTV threshold for margin call (default 0.75)
monthly_budget: Approved monthly hedge budget
ltv_warning: LTV warning level for alerts (default 0.70)
"""
gold_value: float = 215000.0
loan_amount: float = 145000.0
margin_threshold: float = 0.75
monthly_budget: float = 8000.0
ltv_warning: float = 0.70
# Data source settings
primary_source: str = "yfinance"
fallback_source: str = "yfinance"
refresh_interval: int = 5
# Alert settings
volatility_spike: float = 0.25
spot_drawdown: float = 7.5
email_alerts: bool = False
def __post_init__(self):
"""Validate configuration after initialization."""
self.validate()
def validate(self) -> None:
"""Validate configuration values."""
if self.gold_value <= 0:
raise ValueError("Gold value must be positive")
if self.loan_amount < 0:
raise ValueError("Loan amount cannot be negative")
if self.loan_amount >= self.gold_value:
raise ValueError("Loan amount must be less than gold value (LTV < 100%)")
if not 0.1 <= self.margin_threshold <= 0.95:
raise ValueError("Margin threshold must be between 10% and 95%")
if not 0.1 <= self.ltv_warning <= 0.95:
raise ValueError("LTV warning level must be between 10% and 95%")
if self.refresh_interval < 1:
raise ValueError("Refresh interval must be at least 1 second")
@property
def current_ltv(self) -> float:
"""Calculate current loan-to-value ratio."""
if self.gold_value == 0:
return 0.0
return self.loan_amount / self.gold_value
@property
def margin_buffer(self) -> float:
"""Calculate margin buffer (distance to margin call)."""
return self.margin_threshold - self.current_ltv
@property
def net_equity(self) -> float:
"""Calculate net equity (gold value - loan)."""
return self.gold_value - self.loan_amount
@property
def margin_call_price(self) -> float:
"""Calculate gold price at which margin call occurs."""
if self.margin_threshold == 0:
return float('inf')
return self.loan_amount / self.margin_threshold
def to_dict(self) -> dict[str, Any]:
"""Convert configuration to dictionary."""
return {
"gold_value": self.gold_value,
"loan_amount": self.loan_amount,
"margin_threshold": self.margin_threshold,
"monthly_budget": self.monthly_budget,
"ltv_warning": self.ltv_warning,
"primary_source": self.primary_source,
"fallback_source": self.fallback_source,
"refresh_interval": self.refresh_interval,
"volatility_spike": self.volatility_spike,
"spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PortfolioConfig:
"""Create configuration from dictionary."""
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
class PortfolioRepository:
"""Repository for persisting portfolio configuration.
Uses file-based storage by default. Can be extended to use Redis.
"""
CONFIG_PATH = Path("data/portfolio_config.json")
def __init__(self):
# Ensure data directory exists
self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
def save(self, config: PortfolioConfig) -> None:
"""Save configuration to disk."""
with open(self.CONFIG_PATH, "w") as f:
json.dump(config.to_dict(), f, indent=2)
def load(self) -> PortfolioConfig:
"""Load configuration from disk.
Returns default configuration if file doesn't exist.
"""
if not self.CONFIG_PATH.exists():
default = PortfolioConfig()
self.save(default)
return default
try:
with open(self.CONFIG_PATH) as f:
data = json.load(f)
return PortfolioConfig.from_dict(data)
except (json.JSONDecodeError, ValueError) as e:
print(f"Warning: Failed to load portfolio config: {e}. Using defaults.")
return PortfolioConfig()
# Singleton repository instance
_portfolio_repo: PortfolioRepository | None = None
def get_portfolio_repository() -> PortfolioRepository:
"""Get or create global portfolio repository instance."""
global _portfolio_repo
if _portfolio_repo is None:
_portfolio_repo = PortfolioRepository()
return _portfolio_repo

View File

@@ -5,16 +5,22 @@ from typing import Any
from nicegui import ui from nicegui import ui
from app.components import GreeksTable from app.components import GreeksTable
from app.pages.common import dashboard_page, option_chain, strategy_catalog from app.pages.common import dashboard_page, strategy_catalog
from app.services.runtime import get_data_service
@ui.page("/options") @ui.page("/options")
def options_page() -> None: async def options_page() -> None:
chain = option_chain() chain_data = await get_data_service().get_options_chain("GLD")
expiries = sorted({row["expiry"] for row in chain}) chain = list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])])
strike_values = sorted({row["strike"] for row in chain}) expiries = list(chain_data.get("expirations") or sorted({row["expiry"] for row in chain}))
selected_expiry = {"value": expiries[0]} strike_values = sorted({float(row["strike"]) for row in chain})
strike_range = {"min": strike_values[0], "max": strike_values[-1]}
selected_expiry = {"value": expiries[0] if expiries else None}
strike_range = {
"min": strike_values[0] if strike_values else 0.0,
"max": strike_values[-1] if strike_values else 0.0,
}
selected_strategy = {"value": strategy_catalog()[0]["label"]} selected_strategy = {"value": strategy_catalog()[0]["label"]}
chosen_contracts: list[dict[str, Any]] = [] chosen_contracts: list[dict[str, Any]] = []
@@ -32,15 +38,15 @@ def options_page() -> None:
min_strike = ui.number( min_strike = ui.number(
"Min strike", "Min strike",
value=strike_range["min"], value=strike_range["min"],
min=strike_values[0], min=strike_values[0] if strike_values else 0.0,
max=strike_values[-1], max=strike_values[-1] if strike_values else 0.0,
step=5, step=5,
).classes("w-full") ).classes("w-full")
max_strike = ui.number( max_strike = ui.number(
"Max strike", "Max strike",
value=strike_range["max"], value=strike_range["max"],
min=strike_values[0], min=strike_values[0] if strike_values else 0.0,
max=strike_values[-1], max=strike_values[-1] if strike_values else 0.0,
step=5, step=5,
).classes("w-full") ).classes("w-full")
strategy_select = ui.select( strategy_select = ui.select(
@@ -49,6 +55,15 @@ def options_page() -> None:
label="Add to hedge strategy", label="Add to hedge strategy",
).classes("w-full") ).classes("w-full")
source_label = f"Source: {chain_data.get('source', 'unknown')}"
if chain_data.get("updated_at"):
source_label += f" · Updated {chain_data['updated_at']}"
ui.label(source_label).classes("text-xs text-slate-500 dark:text-slate-400")
if chain_data.get("error"):
ui.label(f"Options data unavailable: {chain_data['error']}").classes(
"text-xs text-amber-700 dark:text-amber-300"
)
selection_card = ui.card().classes( 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" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
) )
@@ -56,12 +71,14 @@ def options_page() -> None:
chain_table = ui.html("").classes("w-full") chain_table = ui.html("").classes("w-full")
greeks = GreeksTable([]) greeks = GreeksTable([])
def filtered_rows() -> list[dict]: def filtered_rows() -> list[dict[str, Any]]:
if not selected_expiry["value"]:
return []
return [ return [
row row
for row in chain for row in chain
if row["expiry"] == selected_expiry["value"] if row["expiry"] == selected_expiry["value"]
and strike_range["min"] <= row["strike"] <= strike_range["max"] and strike_range["min"] <= float(row["strike"]) <= strike_range["max"]
] ]
def render_selection() -> None: def render_selection() -> None:
@@ -76,10 +93,10 @@ def options_page() -> None:
return return
for contract in chosen_contracts[-3:]: for contract in chosen_contracts[-3:]:
ui.label( ui.label(
f"{contract['symbol']} · premium ${contract['premium']:.2f} · Δ {contract['delta']:+.3f}" f"{contract['symbol']} · premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}"
).classes("text-sm text-slate-600 dark:text-slate-300") ).classes("text-sm text-slate-600 dark:text-slate-300")
def add_to_strategy(contract: dict) -> None: def add_to_strategy(contract: dict[str, Any]) -> None:
chosen_contracts.append(contract) chosen_contracts.append(contract)
render_selection() render_selection()
greeks.set_options(chosen_contracts[-6:]) greeks.set_options(chosen_contracts[-6:])
@@ -100,6 +117,8 @@ def options_page() -> None:
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Type</th> <th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Type</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Strike</th> <th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Strike</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Bid / Ask</th> <th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Bid / Ask</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Last</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>IV</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Greeks</th> <th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Greeks</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Action</th> <th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Action</th>
</tr> </tr>
@@ -110,16 +129,18 @@ def options_page() -> None:
<tr class='border-b border-slate-200 dark:border-slate-800'> <tr class='border-b border-slate-200 dark:border-slate-800'>
<td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td> <td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['strike']:.2f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['strike']):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['bid']:.2f} / ${row['ask']:.2f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['bid']):.2f} / ${float(row['ask']):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>Δ {row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row.get('lastPrice', row.get('premium', 0.0))):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{float(row.get('impliedVolatility', 0.0)):.1%}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td> <td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr> </tr>
""" for row in rows) """ for row in rows)
+ ( + (
"" ""
if rows if rows
else "<tr><td colspan='6' class='px-4 py-6 text-center text-slate-500 dark:text-slate-400'>No contracts match the current filter.</td></tr>" else "<tr><td colspan='8' class='px-4 py-6 text-center text-slate-500 dark:text-slate-400'>No contracts match the current filter.</td></tr>"
) )
+ """ + """
</tbody> </tbody>
@@ -136,7 +157,7 @@ def options_page() -> None:
with ui.row().classes("w-full gap-2 max-sm:flex-col"): with ui.row().classes("w-full gap-2 max-sm:flex-col"):
for row in rows[:6]: for row in rows[:6]:
ui.button( ui.button(
f"Add {row['type'].upper()} {row['strike']:.0f}", f"Add {row['type'].upper()} {float(row['strike']):.0f}",
on_click=lambda _, contract=row: add_to_strategy(contract), on_click=lambda _, contract=row: add_to_strategy(contract),
).props("outline color=primary") ).props("outline color=primary")
greeks.set_options(rows[:6]) greeks.set_options(rows[:6])
@@ -147,8 +168,8 @@ def options_page() -> None:
def update_filters() -> None: def update_filters() -> None:
selected_expiry["value"] = expiry_select.value selected_expiry["value"] = expiry_select.value
strike_range["min"] = float(min_strike.value) strike_range["min"] = float(min_strike.value or 0.0)
strike_range["max"] = float(max_strike.value) strike_range["max"] = float(max_strike.value or 0.0)
if strike_range["min"] > strike_range["max"]: if strike_range["min"] > strike_range["max"]:
strike_range["min"], strike_range["max"] = ( strike_range["min"], strike_range["max"] = (
strike_range["max"], strike_range["max"],
@@ -161,6 +182,7 @@ def options_page() -> None:
expiry_select.on_value_change(lambda _: update_filters()) expiry_select.on_value_change(lambda _: update_filters())
min_strike.on_value_change(lambda _: update_filters()) min_strike.on_value_change(lambda _: update_filters())
max_strike.on_value_change(lambda _: update_filters()) max_strike.on_value_change(lambda _: update_filters())
def on_strategy_change(event) -> None: def on_strategy_change(event) -> None:
selected_strategy["value"] = event.value # type: ignore[assignment] selected_strategy["value"] = event.value # type: ignore[assignment]
render_selection() render_selection()

View File

@@ -3,10 +3,16 @@ from __future__ import annotations
from nicegui import ui from nicegui import ui
from app.pages.common import dashboard_page from app.pages.common import dashboard_page
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
@ui.page("/settings") @ui.page("/settings")
def settings_page() -> None: def settings_page():
"""Settings page with persistent portfolio configuration."""
# Load current configuration
repo = get_portfolio_repository()
config = repo.load()
with dashboard_page( with dashboard_page(
"Settings", "Settings",
"Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.", "Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.",
@@ -17,12 +23,46 @@ def settings_page() -> None:
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "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") 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") gold_value = ui.number(
margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes( "Gold collateral value ($)",
"w-full" value=config.gold_value,
) min=0.01, # Must be positive
ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full") step=1000
).classes("w-full")
loan_amount = ui.number(
"Loan amount ($)",
value=config.loan_amount,
min=0,
step=1000
).classes("w-full")
margin_threshold = ui.number(
"Margin call LTV threshold",
value=config.margin_threshold,
min=0.1,
max=0.95,
step=0.01
).classes("w-full")
monthly_budget = ui.number(
"Monthly hedge budget ($)",
value=config.monthly_budget,
min=0,
step=500
).classes("w-full")
# Show calculated values
with ui.row().classes("w-full gap-2 mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg"):
ui.label("Current LTV:").classes("font-medium")
ltv_display = ui.label(f"{(config.loan_amount / config.gold_value * 100):.1f}%")
ui.label("Margin buffer:").classes("font-medium ml-4")
buffer_display = ui.label(f"{((config.margin_threshold - config.loan_amount / config.gold_value) * 100):.1f}%")
ui.label("Margin call at:").classes("font-medium ml-4")
margin_price_display = ui.label(f"${(config.loan_amount / config.margin_threshold):,.2f}")
with ui.card().classes( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
@@ -30,53 +70,126 @@ def settings_page() -> None:
ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
primary_source = ui.select( primary_source = ui.select(
["yfinance", "ibkr", "alpaca"], ["yfinance", "ibkr", "alpaca"],
value="yfinance", value=config.primary_source,
label="Primary source", label="Primary source",
).classes("w-full") ).classes("w-full")
fallback_source = ui.select( fallback_source = ui.select(
["fallback", "yfinance", "manual"], ["fallback", "yfinance", "manual"],
value="fallback", value=config.fallback_source,
label="Fallback source", label="Fallback source",
).classes("w-full") ).classes("w-full")
refresh_interval = ui.number("Refresh interval (seconds)", value=5, min=1, step=1).classes("w-full") refresh_interval = ui.number(
ui.switch("Enable Redis cache", value=True) "Refresh interval (seconds)",
value=config.refresh_interval,
min=1,
step=1
).classes("w-full")
def update_calculations():
"""Update calculated displays when values change."""
try:
gold = gold_value.value or 1 # Avoid division by zero
loan = loan_amount.value or 0
margin = margin_threshold.value or 0.75
ltv = (loan / gold) * 100
buffer = (margin - loan / gold) * 100
margin_price = loan / margin if margin > 0 else 0
ltv_display.set_text(f"{ltv:.1f}%")
buffer_display.set_text(f"{buffer:.1f}%")
margin_price_display.set_text(f"${margin_price:,.2f}")
except Exception:
pass # Ignore calculation errors during editing
# Connect update function to value changes
gold_value.on_value_change(update_calculations)
loan_amount.on_value_change(update_calculations)
margin_threshold.on_value_change(update_calculations)
with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.row().classes("w-full gap-6 max-lg:flex-col"):
with ui.card().classes( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "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") 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") ltv_warning = ui.number(
vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes( "LTV warning level",
"w-full" value=config.ltv_warning,
min=0.1,
max=0.95,
step=0.01
).classes("w-full")
vol_alert = ui.number(
"Volatility spike alert",
value=config.volatility_spike,
min=0.01,
max=2.0,
step=0.01
).classes("w-full")
price_alert = ui.number(
"Spot drawdown alert (%)",
value=config.spot_drawdown,
min=0.1,
max=50.0,
step=0.5
).classes("w-full")
email_alerts = ui.switch(
"Email alerts",
value=config.email_alerts
) )
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( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "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") 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( export_format = ui.select(
"w-full" ["json", "csv", "yaml"],
) value="json",
label="Export format"
).classes("w-full")
ui.switch("Include scenario history", value=True) ui.switch("Include scenario history", value=True)
ui.switch("Include option selections", value=True) ui.switch("Include option selections", value=True)
ui.button("Import settings", icon="upload").props("outline color=primary") ui.button("Import settings", icon="upload").props("outline color=primary")
ui.button("Export settings", icon="download").props("outline color=primary") ui.button("Export settings", icon="download").props("outline color=primary")
def save_settings() -> None: def save_settings():
status.set_text( """Save settings with validation and persistence."""
"Saved configuration: " try:
f"gold=${gold_value.value:,.0f}, loan=${loan_amount.value:,.0f}, margin={margin_threshold.value:.2f}, " # Create new config from form values
f"primary={primary_source.value}, fallback={fallback_source.value}, refresh={refresh_interval.value}s, " new_config = PortfolioConfig(
f"ltv warning={ltv_warning.value:.2f}, vol={vol_alert.value:.2f}, drawdown={price_alert.value:.1f}%, " gold_value=float(gold_value.value),
f"email alerts={'on' if email_alerts.value else 'off'}, export={export_format.value}." loan_amount=float(loan_amount.value),
) margin_threshold=float(margin_threshold.value),
ui.notify("Settings saved", color="positive") monthly_budget=float(monthly_budget.value),
ltv_warning=float(ltv_warning.value),
primary_source=str(primary_source.value),
fallback_source=str(fallback_source.value),
refresh_interval=int(refresh_interval.value),
volatility_spike=float(vol_alert.value),
spot_drawdown=float(price_alert.value),
email_alerts=bool(email_alerts.value),
)
# Save to repository
repo.save(new_config)
status.set_text(
f"Saved: gold=${new_config.gold_value:,.0f}, "
f"loan=${new_config.loan_amount:,.0f}, "
f"LTV={new_config.current_ltv:.1%}, "
f"margin={new_config.margin_threshold:.1%}, "
f"buffer={new_config.margin_buffer:.1%}"
)
ui.notify("Settings saved successfully", color="positive")
except ValueError as e:
ui.notify(f"Validation error: {e}", color="negative")
except Exception as e:
ui.notify(f"Failed to save: {e}", color="negative")
with ui.row().classes("w-full items-center justify-between gap-4"): with ui.row().classes("w-full items-center justify-between gap-4 mt-6"):
status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") status = ui.label(
f"Current: gold=${config.gold_value:,.0f}, loan=${config.loan_amount:,.0f}, "
f"current LTV={config.current_ltv:.1%}"
).classes("text-sm text-slate-500 dark:text-slate-400")
ui.button("Save settings", on_click=save_settings).props("color=primary") ui.button("Save settings", on_click=save_settings).props("color=primary")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -57,46 +58,77 @@ class DataService:
return quote return quote
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]: async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper() ticker_symbol = (symbol or self.default_symbol).upper()
cache_key = f"options:{ticker}" cache_key = f"options:{ticker_symbol}"
cached = await self.cache.get_json(cache_key) cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict): if cached and isinstance(cached, dict):
return cached return cached
quote = await self.get_quote(ticker) quote = await self.get_quote(ticker_symbol)
base_price = quote["price"] if yf is None:
options_chain = { options_chain = self._fallback_options_chain(ticker_symbol, quote, source="fallback")
"symbol": ticker, await self.cache.set_json(cache_key, options_chain)
"updated_at": datetime.now(UTC).isoformat(), return options_chain
"calls": [
{ try:
"strike": round(base_price * 1.05, 2), ticker = yf.Ticker(ticker_symbol)
"premium": round(base_price * 0.03, 2), expirations = await asyncio.to_thread(lambda: list(ticker.options or []))
"expiry": "2026-06-19", if not expirations:
}, options_chain = self._fallback_options_chain(
{ ticker_symbol,
"strike": round(base_price * 1.10, 2), quote,
"premium": round(base_price * 0.02, 2), source="fallback",
"expiry": "2026-09-18", error="No option expirations returned by yfinance",
}, )
], await self.cache.set_json(cache_key, options_chain)
"puts": [ return options_chain
{
"strike": round(base_price * 0.95, 2), calls: list[dict[str, Any]] = []
"premium": round(base_price * 0.028, 2), puts: list[dict[str, Any]] = []
"expiry": "2026-06-19",
}, for expiry in expirations:
{ try:
"strike": round(base_price * 0.90, 2), chain = await asyncio.to_thread(ticker.option_chain, expiry)
"premium": round(base_price * 0.018, 2), except Exception as exc: # pragma: no cover - network dependent
"expiry": "2026-09-18", logger.warning("Failed to fetch option chain for %s %s: %s", ticker_symbol, expiry, exc)
}, continue
],
"source": quote["source"], calls.extend(self._normalize_option_rows(chain.calls, ticker_symbol, expiry, "call"))
} puts.extend(self._normalize_option_rows(chain.puts, ticker_symbol, expiry, "put"))
await self.cache.set_json(cache_key, options_chain)
return options_chain if not calls and not puts:
options_chain = self._fallback_options_chain(
ticker_symbol,
quote,
source="fallback",
error="No option contracts returned by yfinance",
)
await self.cache.set_json(cache_key, options_chain)
return options_chain
options_chain = {
"symbol": ticker_symbol,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations,
"calls": calls,
"puts": puts,
"rows": sorted(calls + puts, key=lambda row: (row["expiry"], row["strike"], row["type"])),
"underlying_price": quote["price"],
"source": "yfinance",
}
await self.cache.set_json(cache_key, options_chain)
return options_chain
except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch options chain for %s from yfinance: %s", ticker_symbol, exc)
options_chain = self._fallback_options_chain(
ticker_symbol,
quote,
source="fallback",
error=str(exc),
)
await self.cache.set_json(cache_key, options_chain)
return options_chain
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]: async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper() ticker = (symbol or self.default_symbol).upper()
@@ -149,6 +181,81 @@ class DataService:
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc) logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
return self._fallback_quote(symbol, source="fallback") return self._fallback_quote(symbol, source="fallback")
def _fallback_options_chain(
self,
symbol: str,
quote: dict[str, Any],
*,
source: str,
error: str | None = None,
) -> dict[str, Any]:
options_chain = {
"symbol": symbol,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": [],
"calls": [],
"puts": [],
"rows": [],
"underlying_price": quote["price"],
"source": source,
}
if error:
options_chain["error"] = error
return options_chain
def _normalize_option_rows(self, frame: Any, symbol: str, expiry: str, option_type: str) -> list[dict[str, Any]]:
if frame is None or getattr(frame, "empty", True):
return []
rows: list[dict[str, Any]] = []
for item in frame.to_dict(orient="records"):
strike = self._safe_float(item.get("strike"))
if strike <= 0:
continue
bid = self._safe_float(item.get("bid"))
ask = self._safe_float(item.get("ask"))
last_price = self._safe_float(item.get("lastPrice"))
implied_volatility = self._safe_float(item.get("impliedVolatility"))
contract_symbol = str(item.get("contractSymbol") or "").strip()
rows.append(
{
"contractSymbol": contract_symbol,
"symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}",
"strike": strike,
"bid": bid,
"ask": ask,
"premium": last_price or self._midpoint(bid, ask),
"lastPrice": last_price,
"impliedVolatility": implied_volatility,
"expiry": expiry,
"type": option_type,
"openInterest": int(self._safe_float(item.get("openInterest"))),
"volume": int(self._safe_float(item.get("volume"))),
"delta": 0.0,
"gamma": 0.0,
"theta": 0.0,
"vega": 0.0,
"rho": 0.0,
}
)
return rows
@staticmethod
def _safe_float(value: Any) -> float:
try:
result = float(value)
except (TypeError, ValueError):
return 0.0
return 0.0 if math.isnan(result) else result
@staticmethod
def _midpoint(bid: float, ask: float) -> float:
if bid > 0 and ask > 0:
return round((bid + ask) / 2, 4)
return max(bid, ask, 0.0)
@staticmethod @staticmethod
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]: def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
return { return {

18
app/services/runtime.py Normal file
View File

@@ -0,0 +1,18 @@
"""Runtime service registry for UI pages and background tasks."""
from __future__ import annotations
from app.services.data_service import DataService
_data_service: DataService | None = None
def set_data_service(service: DataService) -> None:
global _data_service
_data_service = service
def get_data_service() -> DataService:
if _data_service is None:
raise RuntimeError("DataService has not been initialized")
return _data_service