Files
PythagorasGoal/src/portfolio/ledger.py
T
Adriano Dal Pastro 432c1ad790 fix(ledger): ribilancio conserva l'equity (no doppio conteggio in-position)
Bug: i flat si dividevano l'INTERO total includendo il capitale dei worker in
posizione, che lo trattenevano in piu' -> equity gonfiata di Sigma(capital-alloc)
sugli in-pos. Caso MR02_BTC 15m seedato (181.19) e in posizione al ribilancio
00:01: +4.77 fantasma. allocate(weights, reserved={sid:cap}): gli in-pos
trattengono il loro, i flat si dividono total-Sigma(reserved) per peso
rinormalizzato -> Sigma alloc == total sempre. Parita' runner intatta
(5.8e-08). Test conservazione (ledger + runner). Suite verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:09:36 +00:00

93 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
class PortfolioLedger:
def __init__(self, code: str, total_capital: float = 1000.0,
data_dir: Path = Path("data/portfolios")):
self.code = code
self.initial_capital = total_capital
self.total_capital = total_capital
self.work_dir = Path(data_dir) / code
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.equity_path = self.work_dir / "equity.jsonl"
self.events_path = self.work_dir / "events.jsonl"
self.equity = total_capital
self.peak = total_capital
self.max_dd = 0.0
self.weights: dict[str, float] = {}
self.alloc: dict[str, float] = {}
self.last_rebalance = ""
self._load()
def _load(self):
if not self.status_path.exists():
return
s = json.loads(self.status_path.read_text())
self.total_capital = s.get("total_capital", self.total_capital)
self.equity = s.get("equity", self.equity)
self.peak = s.get("peak", self.peak)
self.max_dd = s.get("max_dd", self.max_dd)
self.weights = s.get("weights", {})
self.alloc = s.get("alloc", {})
self.last_rebalance = s.get("last_rebalance", "")
def allocate(self, weights: dict[str, float],
reserved: dict[str, float] | None = None) -> dict[str, float]:
"""alloc per sid = peso × total_capital. `reserved` {sid: capitale trattenuto}
per i worker con posizione APERTA: quel capitale e' deployato, non
ridistribuibile -> i flat si dividono (total Σreserved) per peso
RINORMALIZZATO sui soli flat. Cosi' Σalloc == total_capital ed equity e'
CONSERVATA dal ribilancio. Senza reserved (default): comportamento storico
(alloc = peso×total per tutti), corretto solo se tutti i worker sono flat
(allocazione iniziale). Fix 2026-06-13: prima i flat si dividevano l'INTERO
total includendo il capitale degli in-position -> doppio conteggio, equity
gonfiata di Σ(capital_in_pos alloc_in_pos) (caso MR02_BTC 15m seedato e in
posizione al ribilancio: +4.77). Vedi docs/diary/2026-06-13-rebalance-conservation.md."""
self.weights = dict(weights)
reserved = reserved or {}
distributable = self.total_capital - sum(reserved.values())
flat = {sid: w for sid, w in weights.items() if sid not in reserved}
wsum = sum(flat.values())
self.alloc = {sid: round(cap, 6) for sid, cap in reserved.items()}
for sid, w in flat.items():
share = (w / wsum) if wsum > 0 else (1.0 / len(flat) if flat else 0.0)
self.alloc[sid] = round(distributable * share, 6)
self.last_rebalance = datetime.now(timezone.utc).isoformat()
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
"total_capital": self.total_capital,
"reserved": sorted(reserved)})
return self.alloc
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
self.equity = float(sum(sleeve_equity.values()))
if self.equity > self.peak:
self.peak = self.equity
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
self.max_dd = max(self.max_dd, dd)
self._append(self.equity_path, {
"ts": datetime.now(timezone.utc).isoformat(),
"equity": round(self.equity, 2), "dd": round(dd, 3),
"pnl_day": round(pnl_day, 2),
"pnl_total": round(self.equity - self.initial_capital, 2),
})
def save(self):
self.status_path.write_text(json.dumps({
"code": self.code, "total_capital": round(self.total_capital, 2),
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
"max_dd": round(self.max_dd, 3), "weights": self.weights,
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
"ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
@staticmethod
def _append(path: Path, row: dict):
with open(path, "a") as f:
f.write(json.dumps(row) + "\n")