From 432c1ad790b79c7e9aeaedd93201a75cca013272 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 13 Jun 2026 10:09:36 +0000 Subject: [PATCH] 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 --- .../2026-06-13-rebalance-conservation.md | 56 +++++++++++++++++++ src/portfolio/ledger.py | 25 ++++++++- src/portfolio/runner.py | 7 ++- tests/portfolio/test_ledger.py | 17 ++++++ tests/portfolio/test_runner_rebalance.py | 18 ++++-- 5 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 docs/diary/2026-06-13-rebalance-conservation.md diff --git a/docs/diary/2026-06-13-rebalance-conservation.md b/docs/diary/2026-06-13-rebalance-conservation.md new file mode 100644 index 0000000..5f7ef33 --- /dev/null +++ b/docs/diary/2026-06-13-rebalance-conservation.md @@ -0,0 +1,56 @@ +# 2026-06-13 — Equity gonfiata dal ribilancio: +4.77 fantasma (causa + fix) + +## Sintomo + +Report "dall'ultimo reset": equity 2020.53 → 2025.80 (+5.27), ma un solo trade +chiuso (MR02_BTC 15m, reale +0.50). Il +4.77 residuo è comparso di colpo a +00:01:52 (primo poll dopo mezzanotte UTC = ribilancio giornaliero), senza alcuna +chiusura dietro. + +## Causa — doppio conteggio del capitale degli in-position al ribilancio + +`rebalance_allocations` faceva: +1. `total_capital = Σ capital di tutti i worker` +2. `alloc[sid] = peso × total_capital` per OGNI sid +3. ai worker in posizione NON applicava l'alloc (la posizione tiene il suo notional) +4. ai flat: `capital = alloc[sid]` + +`update_equity` (ogni poll) = Σ capital. Dopo il ribilancio la somma diventa: + + Σcapital = Σ(peso_i × total) [flat] + Σ(capital_j) [in-pos] + = total − Σalloc_j + Σcapital_j + = total + Σ(capital_j − alloc_j) ← inflazione + +Cioè i flat si dividevano l'INTERO total (che includeva anche il capitale degli +in-position), e gli in-position lo tenevano in più → doppio conteggio. + +Al ribilancio delle 00:01, **MR02_BTC 15m era in posizione** col capitale seedato +**181.19** (eredità del gemello 1h, INIT_LINEAGE di ieri) mentre la sua allocazione +era ~176.42 → **+4.77** di equity dal nulla. Lo scatenante è stato il seed (capitale +ben sopra l'allocazione media) combinato con la posizione aperta al ribilancio; ma +il bug era latente da sempre (ogni worker in-pos che devia dall'alloc gonfiava/sgonfiava +l'equity al ribilancio successivo). + +## Fix — conservazione (ledger.allocate `reserved`) + +`allocate(weights, reserved={sid: capitale})`: i worker in posizione TRATTENGONO il +loro capitale (deployato, non spostabile); i flat si dividono `total − Σreserved` +per peso **rinormalizzato sui soli flat**. Così `Σalloc == total_capital` sempre → +**l'equity è conservata dal ribilancio** (un ribilancio sposta capitale fra sleeve, +non crea valore). `runner.rebalance_allocations` calcola `reserved` dai worker +`in_position` e lo passa. Senza `reserved` (default) = comportamento storico +(corretto solo a worker tutti flat, es. allocazione iniziale). +Test: `tests/portfolio/test_ledger.py::test_alloc_conserves_total_with_reserved`. + +## Correzione dello stato (rimozione del +4.77 già accumulato) + +Equity riportata a **2021.03** = baseline post-seed verificata (2020.53) + unico +trade reale (MR02_BTC 15m +0.50). Capitali dei worker del pool scalati × +(2021.03/2025.79) e peak del ledger resettato a 2021.03 (il 2025.80 non è mai +stato un massimo legittimo → niente DD fantasma). L'orfano MR02_BTC 1h chiuso a +parte (+0.85) resta sul worker ritirato, fuori dal pool. + +## Esito + +Equity = soldi veri: dal reset il trading ha prodotto **+0.50** (1 trade), non +5.27. +Deploy con codice fixato (rebuild) così il prossimo ribilancio conserva. diff --git a/src/portfolio/ledger.py b/src/portfolio/ledger.py index 765cea7..2ee8c02 100644 --- a/src/portfolio/ledger.py +++ b/src/portfolio/ledger.py @@ -37,12 +37,31 @@ class PortfolioLedger: self.alloc = s.get("alloc", {}) self.last_rebalance = s.get("last_rebalance", "") - def allocate(self, weights: dict[str, float]) -> dict[str, float]: + 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) - self.alloc = {sid: round(self.total_capital * w, 6) for sid, w in weights.items()} + 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}) + "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): diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index c3dc8cf..9f424e6 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -130,7 +130,12 @@ def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[ il suo notional, come da approssimazione dichiarata): il nuovo capitale-base si applica alla prossima posizione, quando il worker è flat.""" ledger.total_capital = sum(_worker_equity(w) for w in workers.values()) - alloc = ledger.allocate(weights) + # i worker in posizione TRATTENGONO il loro capitale (deployato): vanno passati + # come `reserved` ad allocate, cosi' i flat si dividono solo il RESTO e l'equity + # totale e' conservata (fix doppio conteggio 2026-06-13, vedi ledger.allocate). + reserved = {sid: _worker_equity(w) for sid, w in workers.items() + if getattr(getattr(w, "worker", w), "in_position", False)} + alloc = ledger.allocate(weights, reserved=reserved) for sid, w in workers.items(): inner = getattr(w, "worker", w) if getattr(inner, "in_position", False): diff --git a/tests/portfolio/test_ledger.py b/tests/portfolio/test_ledger.py index 31e9dd2..0110f26 100644 --- a/tests/portfolio/test_ledger.py +++ b/tests/portfolio/test_ledger.py @@ -8,6 +8,23 @@ def test_alloc_split_by_weights(tmp_path): assert alloc == {"a": 600.0, "b": 400.0} +def test_alloc_conserves_total_with_reserved(tmp_path): + # un worker in posizione (reserved) trattiene capitale != alloc: i flat si + # dividono il RESTO per peso rinormalizzato e Σalloc == total (no doppio conteggio). + # Regressione del bug 2026-06-13 (MR02_BTC 15m in-pos al ribilancio: +4.77 fantasma). + L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path) + alloc = L.allocate({"a": 0.5, "b": 0.25, "c": 0.25}, reserved={"a": 181.0}) + assert abs(sum(alloc.values()) - 1000.0) < 1e-6 # equity conservata + assert alloc["a"] == 181.0 # in-pos tiene il suo + # b e c si dividono 819 per peso 0.25/0.25 -> 409.5 ciascuno + assert abs(alloc["b"] - 409.5) < 1e-6 and abs(alloc["c"] - 409.5) < 1e-6 + + +def test_alloc_no_reserved_is_legacy(tmp_path): + L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path) + assert L.allocate({"a": 0.6, "b": 0.4}) == {"a": 600.0, "b": 400.0} + + def test_update_tracks_equity_and_dd(tmp_path): L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path) L.update_equity({"a": 700.0, "b": 500.0}) diff --git a/tests/portfolio/test_runner_rebalance.py b/tests/portfolio/test_runner_rebalance.py index 7dfa2ad..988f5e3 100644 --- a/tests/portfolio/test_runner_rebalance.py +++ b/tests/portfolio/test_runner_rebalance.py @@ -16,10 +16,18 @@ def test_rebalance_resizes_flat_workers(tmp_path): assert workers["a"].capital == 600.0 and workers["b"].capital == 600.0 -def test_rebalance_skips_in_position_worker(tmp_path): +def test_rebalance_in_position_conserves_equity(tmp_path): + # FIX 2026-06-13: il worker in posizione trattiene il suo capitale (deployato); + # i flat si dividono il RESTO per peso rinormalizzato -> Σcapital CONSERVATO. + # Prima (bug): i flat prendevano peso×total includendo il capitale dell'in-pos + # -> somma gonfiata (qui sarebbe stata 1300, +100 fantasma). L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path) - workers = {"a": FakeWorker(700.0, in_position=True), "b": FakeWorker(500.0)} - rebalance_allocations(L, workers, {"a": 0.5, "b": 0.5}) + workers = {"a": FakeWorker(700.0, in_position=True), + "b": FakeWorker(300.0), "c": FakeWorker(200.0)} # equity 1200 + rebalance_allocations(L, workers, {"a": 0.5, "b": 0.25, "c": 0.25}) assert L.total_capital == 1200.0 - assert workers["a"].capital == 700.0 # invariato: posizione aperta - assert workers["b"].capital == 600.0 # flat: riallineato + assert workers["a"].capital == 700.0 # invariato: posizione aperta (reserved) + # b e c si dividono 1200-700=500 per peso 0.25/0.25 rinormalizzato -> 250 ciascuno + assert workers["b"].capital == 250.0 and workers["c"].capital == 250.0 + total = sum(w.capital for w in workers.values()) + assert total == 1200.0 # equity CONSERVATA dal ribilancio