- single spec grammar for symbol and benchmark fields: commas join one portfolio (MSFT:0.6,V:0.4), spaces separate distinct symbols/portfolios; both fields accept one or many entries - benchmarks simulated with the same scheme/cost/tax rules; per-benchmark beta/alpha columns; after-tax benchmark curves - global Curve mode (pre/after/both) above the tabs; clean names in single-curve mode - live updates: field commits on Enter/blur, page recomputes per rerun; portfolio+tax sims cached (st.cache_data); plotly.js from CDN (4.6MB -> browser-cached) with F_INLINE_PLOTLY=1 offline fallback - chart: legend underneath, solid lines, pan sticks to data edges (width-preserving), zoom edge-clamped - inputs persist in settings.json across reloads/restarts/devices - tests: tests/test_app.py (AppTest) + tests/test_e2e_browser.py (Playwright) via ./run_tests.sh
43 lines
1.2 KiB
Python
43 lines
1.2 KiB
Python
"""Saved portfolio definitions, persisted to portfolios.json next to the app.
|
|
|
|
A portfolio is fully defined by its symbol/weight spec, rebalance scheme
|
|
and cost. Tax rates and the analysis period are global (sidebar), not part
|
|
of the saved definition.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
FILE = Path(__file__).parent / "portfolios.json"
|
|
|
|
|
|
@dataclass
|
|
class Portfolio:
|
|
name: str
|
|
spec: str # "sym1:0.5, sym2:0.5" (parsed by portfolio.parse_weights)
|
|
scheme: str | None # rebalance freq alias ("1W", "1ME", "QE", "YE") or None
|
|
cost_bps: float = 0.0
|
|
|
|
|
|
def load_all() -> list[Portfolio]:
|
|
if not FILE.exists():
|
|
return []
|
|
try:
|
|
return [Portfolio(**d) for d in json.loads(FILE.read_text())]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def save(p: Portfolio) -> None:
|
|
all_p = {q.name: q for q in load_all()}
|
|
all_p[p.name] = p
|
|
FILE.write_text(json.dumps([asdict(q) for q in all_p.values()], indent=2))
|
|
|
|
|
|
def delete(name: str) -> None:
|
|
all_p = [q for q in load_all() if q.name != name]
|
|
FILE.write_text(json.dumps([asdict(q) for q in all_p], indent=2))
|