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:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user