78 lines
3.3 KiB
Python
78 lines
3.3 KiB
Python
"""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)
|