Compare commits
4 Commits
feature/DA
...
feature/DA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ec625146 | ||
|
|
c14ff83adc | ||
|
|
77456c0cb4 | ||
|
|
80a8ffae0c |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center justify-between gap-4"):
|
# Save to repository
|
||||||
status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
|
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 mt-6"):
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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
18
app/services/runtime.py
Normal 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
|
||||||
Reference in New Issue
Block a user