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>
This commit is contained in:
Adriano Dal Pastro
2026-06-13 10:09:36 +00:00
parent 85043bf2d3
commit 432c1ad790
5 changed files with 114 additions and 9 deletions
@@ -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.
+22 -3
View File
@@ -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):
+6 -1
View File
@@ -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):
+17
View File
@@ -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})
+13 -5
View File
@@ -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