"""Paper portfolio ledger for the first vertical slice.""" from __future__ import annotations import json from copy import deepcopy from datetime import datetime, timezone from math import isfinite from pathlib import Path from uuid import uuid4 PAPER_LEDGER_SCHEMA_VERSION = 1 class PaperLedgerError(ValueError): """Raised when a persistent paper ledger is invalid or unreadable.""" class PaperLedger: def __init__(self, path: Path | str | None = None) -> None: self.path = Path(path).resolve() if path else None self._entries: list[dict] = [] if self.path and self.path.is_file(): self._entries = self._load() def _load(self) -> list[dict]: if self.path is None: raise PaperLedgerError("paper ledger path is not configured") try: payload = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise PaperLedgerError("paper ledger is unreadable") from exc if not isinstance(payload, dict) or payload.get("schema_version") != PAPER_LEDGER_SCHEMA_VERSION or not isinstance(payload.get("entries"), list): raise PaperLedgerError("unsupported paper ledger schema") if not all(isinstance(entry, dict) for entry in payload["entries"]): raise PaperLedgerError("paper ledger entries are invalid") return deepcopy(payload["entries"]) def _persist(self, entries: list[dict]) -> None: if not self.path: return self.path.parent.mkdir(parents=True, exist_ok=True) payload = {"schema_version": PAPER_LEDGER_SCHEMA_VERSION, "entries": entries} temporary = self.path.with_name(f".{self.path.name}.tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(self.path) def record(self, payload: dict, allowed_symbols: set[str]) -> dict: symbol = str(payload.get("symbol", "")).strip().upper() if symbol not in allowed_symbols: raise ValueError(f"unknown signal symbol: {symbol}") try: target_weight = float(payload["target_weight"]) assumed_price = float(payload["assumed_price"]) except (KeyError, TypeError, ValueError) as exc: raise ValueError("target_weight and assumed_price must be numeric") from exc if not isfinite(target_weight) or not isfinite(assumed_price): raise ValueError("target_weight and assumed_price must be finite") if not -1.0 <= target_weight <= 1.0: raise ValueError("target_weight must be between -1 and 1") if assumed_price <= 0: raise ValueError("assumed_price must be positive") entry = { "entry_id": f"paper_{uuid4().hex}", "created_at": datetime.now(timezone.utc).isoformat(), "symbol": symbol, "target_weight": target_weight, "assumed_price": assumed_price, "status": "PAPER_RECORDED", } candidate_entries = [*self._entries, entry] self._persist(candidate_entries) self._entries = candidate_entries return deepcopy(entry) def entries(self) -> list[dict]: return deepcopy(self._entries)