Initial commit: Vault Dashboard for options hedging
- FastAPI + NiceGUI web application - QuantLib-based Black-Scholes pricing with Greeks - Protective put, laddered, and LEAPS strategies - Real-time WebSocket updates - TradingView-style charts via Lightweight-Charts - Docker containerization - GitLab CI/CD pipeline for VPS deployment - VPN-only access configuration
This commit is contained in:
72
app/services/cache.py
Normal file
72
app/services/cache.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Redis-backed caching utilities with graceful fallback support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from redis.asyncio import Redis
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
Redis = None
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Small async cache wrapper around Redis."""
|
||||
|
||||
def __init__(self, url: str | None, default_ttl: int = 300) -> None:
|
||||
self.url = url
|
||||
self.default_ttl = default_ttl
|
||||
self._client: Redis | None = None
|
||||
self._enabled = bool(url and Redis)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled and self._client is not None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if not self._enabled:
|
||||
if self.url and Redis is None:
|
||||
logger.warning("Redis URL configured but redis package is not installed; cache disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
self._client = Redis.from_url(self.url, decode_responses=True)
|
||||
await self._client.ping()
|
||||
logger.info("Connected to Redis cache")
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.warning("Redis unavailable, cache disabled: %s", exc)
|
||||
self._client = None
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | list[Any] | None:
|
||||
if self._client is None:
|
||||
return None
|
||||
|
||||
value = await self._client.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return json.loads(value)
|
||||
|
||||
async def set_json(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
payload = json.dumps(value, default=self._json_default)
|
||||
await self._client.set(key, payload, ex=ttl or self.default_ttl)
|
||||
|
||||
@staticmethod
|
||||
def _json_default(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
||||
145
app/services/data_service.py
Normal file
145
app/services/data_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Market data access layer with caching support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.cache import CacheService
|
||||
from app.strategies.engine import StrategySelectionEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
yf = None
|
||||
|
||||
|
||||
class DataService:
|
||||
"""Fetches portfolio and market data, using Redis when available."""
|
||||
|
||||
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None:
|
||||
self.cache = cache
|
||||
self.default_symbol = default_symbol
|
||||
|
||||
async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
cache_key = f"portfolio:{ticker}"
|
||||
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
quote = await self.get_quote(ticker)
|
||||
portfolio = {
|
||||
"symbol": ticker,
|
||||
"spot_price": quote["price"],
|
||||
"portfolio_value": round(quote["price"] * 1000, 2),
|
||||
"loan_amount": 600_000.0,
|
||||
"ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4),
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"source": quote["source"],
|
||||
}
|
||||
await self.cache.set_json(cache_key, portfolio)
|
||||
return portfolio
|
||||
|
||||
async def get_quote(self, symbol: str) -> dict[str, Any]:
|
||||
cache_key = f"quote:{symbol}"
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
quote = await self._fetch_quote(symbol)
|
||||
await self.cache.set_json(cache_key, quote)
|
||||
return quote
|
||||
|
||||
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
cache_key = f"options:{ticker}"
|
||||
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
quote = await self.get_quote(ticker)
|
||||
base_price = quote["price"]
|
||||
options_chain = {
|
||||
"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"},
|
||||
],
|
||||
"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"},
|
||||
],
|
||||
"source": quote["source"],
|
||||
}
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
|
||||
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
quote = await self.get_quote(ticker)
|
||||
engine = StrategySelectionEngine(spot_price=quote["price"] if ticker != "GLD" else 460.0)
|
||||
|
||||
return {
|
||||
"symbol": ticker,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"paper_parameters": {
|
||||
"portfolio_value": engine.portfolio_value,
|
||||
"loan_amount": engine.loan_amount,
|
||||
"margin_call_threshold": engine.margin_call_threshold,
|
||||
"spot_price": engine.spot_price,
|
||||
"volatility": engine.volatility,
|
||||
"risk_free_rate": engine.risk_free_rate,
|
||||
},
|
||||
"strategies": engine.compare_all_strategies(),
|
||||
"recommendations": {
|
||||
profile: engine.recommend(profile)
|
||||
for profile in ("conservative", "balanced", "cost_sensitive")
|
||||
},
|
||||
"sensitivity_analysis": engine.sensitivity_analysis(),
|
||||
}
|
||||
|
||||
async def _fetch_quote(self, symbol: str) -> dict[str, Any]:
|
||||
if yf is None:
|
||||
return self._fallback_quote(symbol, source="fallback")
|
||||
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
history = await asyncio.to_thread(ticker.history, period="5d", interval="1d")
|
||||
if history.empty:
|
||||
return self._fallback_quote(symbol, source="fallback")
|
||||
|
||||
closes = history["Close"]
|
||||
last = float(closes.iloc[-1])
|
||||
previous = float(closes.iloc[-2]) if len(closes) > 1 else last
|
||||
change = round(last - previous, 4)
|
||||
change_percent = round((change / previous) * 100, 4) if previous else 0.0
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"price": round(last, 4),
|
||||
"change": change,
|
||||
"change_percent": change_percent,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"source": "yfinance",
|
||||
}
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
|
||||
return self._fallback_quote(symbol, source="fallback")
|
||||
|
||||
@staticmethod
|
||||
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"price": 215.0,
|
||||
"change": 0.0,
|
||||
"change_percent": 0.0,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"source": source,
|
||||
}
|
||||
Reference in New Issue
Block a user