chore: enforce linting as part of build

This commit is contained in:
Bu5hm4nn
2026-03-24 00:26:36 +01:00
parent de03bd0064
commit 140a21c0b6
8 changed files with 58 additions and 47 deletions

View File

@@ -30,6 +30,11 @@
- Iterate toward partial progress while keeping the failure scoped and observable (Orange). - Iterate toward partial progress while keeping the failure scoped and observable (Orange).
- Finish only when the behavior is implemented and the test loop passes cleanly (Green). - Finish only when the behavior is implemented and the test loop passes cleanly (Green).
7. **Linting is part of the build, not an optional extra step.**
- `make build` must enforce linting first.
- Do not treat lint as a separate, skippable preflight.
- If the build is green, lint must already be green.
## Project Learnings ## Project Learnings
### NiceGUI layout constraint ### NiceGUI layout constraint

View File

@@ -1,4 +1,4 @@
.PHONY: install dev test build deploy .PHONY: install dev lint test build deploy
install: install:
python3 -m venv .venv python3 -m venv .venv
@@ -7,10 +7,14 @@ install:
dev: dev:
. .venv/bin/activate && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 . .venv/bin/activate && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
lint:
. .venv/bin/activate && ruff check app tests scripts
. .venv/bin/activate && black --check app tests scripts
test: test:
. .venv/bin/activate && pytest . .venv/bin/activate && pytest
build: build: lint
docker build -t vault-dash . docker build -t vault-dash .
deploy: deploy:

View File

@@ -64,7 +64,7 @@ class LombardPortfolio:
@dataclass @dataclass
class PortfolioConfig: class PortfolioConfig:
"""User portfolio configuration with validation. """User portfolio configuration with validation.
Attributes: Attributes:
gold_value: Current gold collateral value in USD gold_value: Current gold collateral value in USD
loan_amount: Outstanding loan amount in USD loan_amount: Outstanding loan amount in USD
@@ -72,27 +72,27 @@ class PortfolioConfig:
monthly_budget: Approved monthly hedge budget monthly_budget: Approved monthly hedge budget
ltv_warning: LTV warning level for alerts (default 0.70) ltv_warning: LTV warning level for alerts (default 0.70)
""" """
gold_value: float = 215000.0 gold_value: float = 215000.0
loan_amount: float = 145000.0 loan_amount: float = 145000.0
margin_threshold: float = 0.75 margin_threshold: float = 0.75
monthly_budget: float = 8000.0 monthly_budget: float = 8000.0
ltv_warning: float = 0.70 ltv_warning: float = 0.70
# Data source settings # Data source settings
primary_source: str = "yfinance" primary_source: str = "yfinance"
fallback_source: str = "yfinance" fallback_source: str = "yfinance"
refresh_interval: int = 5 refresh_interval: int = 5
# Alert settings # Alert settings
volatility_spike: float = 0.25 volatility_spike: float = 0.25
spot_drawdown: float = 7.5 spot_drawdown: float = 7.5
email_alerts: bool = False email_alerts: bool = False
def __post_init__(self): def __post_init__(self):
"""Validate configuration after initialization.""" """Validate configuration after initialization."""
self.validate() self.validate()
def validate(self) -> None: def validate(self) -> None:
"""Validate configuration values.""" """Validate configuration values."""
if self.gold_value <= 0: if self.gold_value <= 0:
@@ -107,31 +107,31 @@ class PortfolioConfig:
raise ValueError("LTV warning level must be between 10% and 95%") raise ValueError("LTV warning level must be between 10% and 95%")
if self.refresh_interval < 1: if self.refresh_interval < 1:
raise ValueError("Refresh interval must be at least 1 second") raise ValueError("Refresh interval must be at least 1 second")
@property @property
def current_ltv(self) -> float: def current_ltv(self) -> float:
"""Calculate current loan-to-value ratio.""" """Calculate current loan-to-value ratio."""
if self.gold_value == 0: if self.gold_value == 0:
return 0.0 return 0.0
return self.loan_amount / self.gold_value return self.loan_amount / self.gold_value
@property @property
def margin_buffer(self) -> float: def margin_buffer(self) -> float:
"""Calculate margin buffer (distance to margin call).""" """Calculate margin buffer (distance to margin call)."""
return self.margin_threshold - self.current_ltv return self.margin_threshold - self.current_ltv
@property @property
def net_equity(self) -> float: def net_equity(self) -> float:
"""Calculate net equity (gold value - loan).""" """Calculate net equity (gold value - loan)."""
return self.gold_value - self.loan_amount return self.gold_value - self.loan_amount
@property @property
def margin_call_price(self) -> float: def margin_call_price(self) -> float:
"""Calculate gold price at which margin call occurs.""" """Calculate gold price at which margin call occurs."""
if self.margin_threshold == 0: if self.margin_threshold == 0:
return float('inf') return float("inf")
return self.loan_amount / self.margin_threshold return self.loan_amount / self.margin_threshold
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Convert configuration to dictionary.""" """Convert configuration to dictionary."""
return { return {
@@ -147,7 +147,7 @@ class PortfolioConfig:
"spot_drawdown": self.spot_drawdown, "spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts, "email_alerts": self.email_alerts,
} }
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> PortfolioConfig: def from_dict(cls, data: dict[str, Any]) -> PortfolioConfig:
"""Create configuration from dictionary.""" """Create configuration from dictionary."""
@@ -156,31 +156,31 @@ class PortfolioConfig:
class PortfolioRepository: class PortfolioRepository:
"""Repository for persisting portfolio configuration. """Repository for persisting portfolio configuration.
Uses file-based storage by default. Can be extended to use Redis. Uses file-based storage by default. Can be extended to use Redis.
""" """
CONFIG_PATH = Path("data/portfolio_config.json") CONFIG_PATH = Path("data/portfolio_config.json")
def __init__(self): def __init__(self):
# Ensure data directory exists # Ensure data directory exists
self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
def save(self, config: PortfolioConfig) -> None: def save(self, config: PortfolioConfig) -> None:
"""Save configuration to disk.""" """Save configuration to disk."""
with open(self.CONFIG_PATH, "w") as f: with open(self.CONFIG_PATH, "w") as f:
json.dump(config.to_dict(), f, indent=2) json.dump(config.to_dict(), f, indent=2)
def load(self) -> PortfolioConfig: def load(self) -> PortfolioConfig:
"""Load configuration from disk. """Load configuration from disk.
Returns default configuration if file doesn't exist. Returns default configuration if file doesn't exist.
""" """
if not self.CONFIG_PATH.exists(): if not self.CONFIG_PATH.exists():
default = PortfolioConfig() default = PortfolioConfig()
self.save(default) self.save(default)
return default return default
try: try:
with open(self.CONFIG_PATH) as f: with open(self.CONFIG_PATH) as f:
data = json.load(f) data = json.load(f)

View File

@@ -81,6 +81,7 @@ def get_cache() -> CacheService:
global _cache_instance global _cache_instance
if _cache_instance is None: if _cache_instance is None:
import os import os
redis_url = os.environ.get("REDIS_URL") redis_url = os.environ.get("REDIS_URL")
_cache_instance = CacheService(redis_url) _cache_instance = CacheService(redis_url)
return _cache_instance return _cache_instance

View File

@@ -110,7 +110,9 @@ class DataService:
await self.cache.set_json(cache_key, payload) await self.cache.set_json(cache_key, payload)
return payload return payload
async def get_options_chain_for_expiry(self, symbol: str | None = None, expiry: str | None = None) -> dict[str, Any]: async def get_options_chain_for_expiry(
self, symbol: str | None = None, expiry: str | None = None
) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper() ticker_symbol = (symbol or self.default_symbol).upper()
expirations_data = await self.get_option_expirations(ticker_symbol) expirations_data = await self.get_option_expirations(ticker_symbol)
expirations = list(expirations_data.get("expirations") or []) expirations = list(expirations_data.get("expirations") or [])
@@ -176,7 +178,9 @@ class DataService:
await self.cache.set_json(cache_key, payload) await self.cache.set_json(cache_key, payload)
return payload return payload
except Exception as exc: # pragma: no cover - network dependent except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch options chain for %s %s from yfinance: %s", ticker_symbol, target_expiry, exc) logger.warning(
"Failed to fetch options chain for %s %s from yfinance: %s", ticker_symbol, target_expiry, exc
)
payload = self._fallback_options_chain( payload = self._fallback_options_chain(
ticker_symbol, ticker_symbol,
quote, quote,

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime
from typing import Optional from typing import Optional
import yfinance as yf import yfinance as yf
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class PriceData: class PriceData:
"""Price data for a symbol.""" """Price data for a symbol."""
symbol: str symbol: str
price: float price: float
currency: str currency: str
@@ -25,19 +26,19 @@ class PriceData:
class PriceFeed: class PriceFeed:
"""Live price feed service using yfinance with Redis caching.""" """Live price feed service using yfinance with Redis caching."""
CACHE_TTL_SECONDS = 60 CACHE_TTL_SECONDS = 60
DEFAULT_SYMBOLS = ["GLD", "TLT", "BTC-USD"] DEFAULT_SYMBOLS = ["GLD", "TLT", "BTC-USD"]
def __init__(self): def __init__(self):
self._cache = get_cache() self._cache = get_cache()
async def get_price(self, symbol: str) -> Optional[PriceData]: async def get_price(self, symbol: str) -> Optional[PriceData]:
"""Get current price for a symbol, with caching. """Get current price for a symbol, with caching.
Args: Args:
symbol: Yahoo Finance symbol (e.g., "GLD", "BTC-USD") symbol: Yahoo Finance symbol (e.g., "GLD", "BTC-USD")
Returns: Returns:
PriceData or None if fetch fails PriceData or None if fetch fails
""" """
@@ -47,7 +48,7 @@ class PriceFeed:
cached = await self._cache.get(cache_key) cached = await self._cache.get(cache_key)
if cached: if cached:
return PriceData(**cached) return PriceData(**cached)
# Fetch from yfinance # Fetch from yfinance
try: try:
data = await self._fetch_yfinance(symbol) data = await self._fetch_yfinance(symbol)
@@ -55,44 +56,39 @@ class PriceFeed:
# Cache the result # Cache the result
if self._cache.enabled: if self._cache.enabled:
await self._cache.set( await self._cache.set(
cache_key, cache_key,
{ {
"symbol": data.symbol, "symbol": data.symbol,
"price": data.price, "price": data.price,
"currency": data.currency, "currency": data.currency,
"timestamp": data.timestamp.isoformat(), "timestamp": data.timestamp.isoformat(),
"source": data.source "source": data.source,
}, },
ttl=self.CACHE_TTL_SECONDS ttl=self.CACHE_TTL_SECONDS,
) )
return data return data
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch price for {symbol}: {e}") logger.error(f"Failed to fetch price for {symbol}: {e}")
return None return None
async def _fetch_yfinance(self, symbol: str) -> Optional[PriceData]: async def _fetch_yfinance(self, symbol: str) -> Optional[PriceData]:
"""Fetch price from yfinance (run in thread pool to avoid blocking).""" """Fetch price from yfinance (run in thread pool to avoid blocking)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._sync_fetch_yfinance, symbol) return await loop.run_in_executor(None, self._sync_fetch_yfinance, symbol)
def _sync_fetch_yfinance(self, symbol: str) -> Optional[PriceData]: def _sync_fetch_yfinance(self, symbol: str) -> Optional[PriceData]:
"""Synchronous yfinance fetch.""" """Synchronous yfinance fetch."""
ticker = yf.Ticker(symbol) ticker = yf.Ticker(symbol)
hist = ticker.history(period="1d", interval="1m") hist = ticker.history(period="1d", interval="1m")
if not hist.empty: if not hist.empty:
last_price = hist["Close"].iloc[-1] last_price = hist["Close"].iloc[-1]
currency = ticker.info.get("currency", "USD") currency = ticker.info.get("currency", "USD")
return PriceData( return PriceData(symbol=symbol, price=float(last_price), currency=currency, timestamp=datetime.utcnow())
symbol=symbol,
price=float(last_price),
currency=currency,
timestamp=datetime.utcnow()
)
return None return None
async def get_prices(self, symbols: list[str]) -> dict[str, Optional[PriceData]]: async def get_prices(self, symbols: list[str]) -> dict[str, Optional[PriceData]]:
"""Get prices for multiple symbols concurrently.""" """Get prices for multiple symbols concurrently."""
tasks = [self.get_price(s) for s in symbols] tasks = [self.get_price(s) for s in symbols]

View File

@@ -13,6 +13,7 @@ select = ["E4", "E7", "E9", "F", "I"]
[tool.black] [tool.black]
line-length = 120 line-length = 120
target-version = ["py312"]
extend-exclude = ''' extend-exclude = '''
/( /(
app/components app/components

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from playwright.sync_api import Page, expect, sync_playwright from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8000" BASE_URL = "http://127.0.0.1:8000"
ARTIFACTS = Path("tests/artifacts") ARTIFACTS = Path("tests/artifacts")