feat(live): REAL-TRUTH ledger — capital aggiornato dal PnL dei fill reali, sim ridotto a diagnostica
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,17 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
|
|||||||
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
||||||
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
||||||
diario `docs/diary/2026-06-07-pairspos-gate.md`.
|
diario `docs/diary/2026-06-07-pairspos-gate.md`.
|
||||||
|
- **REAL-TRUTH ledger (2026-06-10, scelta utente).** Con `overrides.execution.real_truth: true`
|
||||||
|
(ATTIVO) il `capital` dei worker eseguiti si aggiorna col **PnL dei FILL REALI** (fee reali
|
||||||
|
incluse) invece del PnL sim: `_real_close`/`_real_close_pair` ritornano `(real_pnl, applied)` e
|
||||||
|
`_close_position`/`_close` applicano il reale al ledger; il sim resta SOLO diagnostica nel log
|
||||||
|
CLOSE (`pnl_source`/`sim_pnl`/`real_pnl`). Fallback al sim dichiarato (`pnl_source=sim_fallback`)
|
||||||
|
solo se il trade reale non è mai esistito/fillato (REAL_OPEN_FAIL/leg-fail). Equity → pesi →
|
||||||
|
allocazioni → notional derivano così dai soldi veri sul conto (il notional reale era GIÀ la
|
||||||
|
formula sim `capital·ps·lev`; il gap storico sim/reale era contabile: ledger separati + spike
|
||||||
|
print delle candele testnet che il sim bookava e il reale no). Le DECISIONI (entry/exit) restano
|
||||||
|
guidate dal feed; i multi-asset (TR01/ROT02/TSM01/XS01) restano sim per costruzione. Test:
|
||||||
|
`tests/portfolio/test_real_truth.py`. Diario `docs/diary/2026-06-10-real-truth-ledger.md`.
|
||||||
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
|
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
|
||||||
|
|
||||||
## Multi-Strategy Paper Trader
|
## Multi-Strategy Paper Trader
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 2026-06-10 — REAL-TRUTH: il ledger segue i fill reali (sim → diagnostica)
|
||||||
|
|
||||||
|
## Richiesta
|
||||||
|
|
||||||
|
L'utente vuole che sim e reale coincidano — o meglio, che il sim non esista come
|
||||||
|
verità: «voglio vedere dati che hanno avuto movimento reale su exchange». La
|
||||||
|
ricognizione dei 77 eventi reali (dal 2026-06-03) ha mostrato che il gap
|
||||||
|
sim/reale NON era slippage (piccolo, es. −0.15 bps su un TP) ma **contabile**:
|
||||||
|
|
||||||
|
1. **Ledger separati.** Il `capital` che guida portafoglio/ribilanci/sizing era
|
||||||
|
aggiornato dal PnL SIM; il PnL reale finiva in un `real_capital` parallelo che
|
||||||
|
non guidava nulla. MR01 BTC: sim +28.93 vs reale +1.98 sugli stessi 4 trade.
|
||||||
|
2. **Prezzi sim da candele testnet** (spike print, es. 2026-06-07 sim short BTC a
|
||||||
|
65266.5 con mark reale 62395): il sim bookava PnL che il reale non vede. Con
|
||||||
|
il ledger sim come verità, l'equity del portafoglio accumulava questa fantasia.
|
||||||
|
|
||||||
|
Nota: il *notional* reale era GIÀ derivato dalla formula sim
|
||||||
|
(`capital·ps·lev` passato a `_real_open`/`_real_open_pair`) — il punto 1 della
|
||||||
|
richiesta (sizing allineato) si chiude da solo una volta che `capital` è reale.
|
||||||
|
|
||||||
|
## Implementazione (REAL-TRUTH)
|
||||||
|
|
||||||
|
- `StrategyWorker` e `PairsWorker` accettano `real_truth: bool` (default False =
|
||||||
|
shadow storico). Con flag attivo e esecuzione abilitata:
|
||||||
|
- `_real_close`/`_real_close_pair` ritornano `(real_pnl, applied)`;
|
||||||
|
`applied=True` se ci sono fill reali (o chiusura verificata).
|
||||||
|
- `_close_position`/`_close` chiamano la chiusura reale PRIMA dell'update
|
||||||
|
ledger: `capital += real_pnl` (fee reali incluse); `is_win = pnl_reale > 0`.
|
||||||
|
- Il sim resta nel log CLOSE come diagnostica: `pnl_source` ("real" |
|
||||||
|
"sim_fallback"), `sim_pnl`, `real_pnl`.
|
||||||
|
- **Fallback al sim** SOLO se il trade reale non è mai esistito/fillato
|
||||||
|
(REAL_OPEN_FAIL, fill zero) — dichiarato nel log, mai silenzioso.
|
||||||
|
- Runner: `overrides.execution.real_truth` (yml) → `build_worker_for(...,
|
||||||
|
real_truth=)`. `portfolios.yml`: **`real_truth: true`**.
|
||||||
|
- Conseguenza a catena: equity ledger → pesi → allocazioni → notional dei
|
||||||
|
prossimi ordini derivano ora dai soldi veri sul conto. Il `real_capital`
|
||||||
|
parallelo resta come ledger puro-reale di confronto.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
`tests/portfolio/test_real_truth.py` (6 test): capital segue i fill reali
|
||||||
|
(single-leg e pairs), una divergenza sim-win/reale-loss viene contata come LOSS,
|
||||||
|
fallback sim dichiarato su REAL_OPEN_FAIL/leg-fail, e modalità shadow invariata
|
||||||
|
senza flag. Suite completa: 99 passed.
|
||||||
|
|
||||||
|
## Limiti onesti
|
||||||
|
|
||||||
|
- I multi-asset (TR01/ROT02/TSM01/XS01) restano sim per costruzione (paper
|
||||||
|
sleeves fuori dal pool, capitale insufficiente per i book multi-leg).
|
||||||
|
- La STORIA del ledger non è riscritta: l'equity attuale (~2154) ingloba il PnL
|
||||||
|
sim accumulato fino a oggi (reale realizzato era ≈ −15.6 dal 3/6). Da ora la
|
||||||
|
divergenza smette di accumularsi; se si vuole un azzeramento (reset equity al
|
||||||
|
conto reale) è un'operazione separata e deliberata.
|
||||||
|
- Le DECISIONI di trading (entry/exit) restano guidate dai prezzi del feed
|
||||||
|
(candele testnet): real-truth corregge la contabilità, non i segnali. Gli
|
||||||
|
spike print del feed possono ancora generare entry/exit subottimali — ma ora
|
||||||
|
il loro effetto si misura in PnL reale, non in PnL immaginario.
|
||||||
@@ -56,3 +56,10 @@ overrides:
|
|||||||
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
||||||
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
|
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
|
||||||
disaster_sl_pct: 0.30
|
disaster_sl_pct: 0.30
|
||||||
|
# REAL-TRUTH (2026-06-10, scelta utente): il ledger `capital` degli sleeve
|
||||||
|
# eseguiti si aggiorna col PnL dei FILL REALI (fee reali incluse) — il sim
|
||||||
|
# resta solo diagnostica nel log CLOSE (pnl_source/sim_pnl/real_pnl).
|
||||||
|
# Fallback al sim SOLO se il trade reale non e' mai esistito/fillato
|
||||||
|
# (REAL_OPEN_FAIL): flag pnl_source=sim_fallback nel log. Cosi' equity,
|
||||||
|
# ribilanci e sizing derivano dai soldi veri sul conto, non dal sim.
|
||||||
|
real_truth: true
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class PairsWorker:
|
|||||||
data_dir: Path = Path("data/paper_trades"),
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe
|
executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe
|
||||||
exec_instruments: dict | None = None, # {asset: instrument USDC}
|
exec_instruments: dict | None = None, # {asset: instrument USDC}
|
||||||
|
real_truth: bool = False,
|
||||||
):
|
):
|
||||||
self.asset_a = asset_a
|
self.asset_a = asset_a
|
||||||
self.asset_b = asset_b
|
self.asset_b = asset_b
|
||||||
@@ -88,6 +89,9 @@ class PairsWorker:
|
|||||||
self.inst_a = self.exec_instruments.get(asset_a)
|
self.inst_a = self.exec_instruments.get(asset_a)
|
||||||
self.inst_b = self.exec_instruments.get(asset_b)
|
self.inst_b = self.exec_instruments.get(asset_b)
|
||||||
self.execution_enabled = bool(executor and self.inst_a and self.inst_b)
|
self.execution_enabled = bool(executor and self.inst_a and self.inst_b)
|
||||||
|
# REAL-TRUTH (2026-06-10): come StrategyWorker — `capital` aggiornato dal
|
||||||
|
# PnL dei fill reali (2 gambe, fee reali); sim solo diagnostica nel log.
|
||||||
|
self.real_truth = bool(real_truth and self.execution_enabled)
|
||||||
self.real_capital = capital
|
self.real_capital = capital
|
||||||
self.real_in_position = False
|
self.real_in_position = False
|
||||||
self.real_dir = 0
|
self.real_dir = 0
|
||||||
@@ -231,11 +235,15 @@ class PairsWorker:
|
|||||||
self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes})
|
self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes})
|
||||||
self._save_state() # persisti subito il ledger reale (resume-safe sui crash)
|
self._save_state() # persisti subito il ledger reale (resume-safe sui crash)
|
||||||
|
|
||||||
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str, sim_pnl: float):
|
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
|
||||||
|
sim_pnl: float) -> tuple[float | None, bool]:
|
||||||
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia
|
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia
|
||||||
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo."""
|
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.
|
||||||
|
|
||||||
|
Ritorna (real_pnl, applied): applied=True se almeno una gamba ha chiuso con
|
||||||
|
fill verificato (PnL reale utilizzabile come verita' in real-truth)."""
|
||||||
if not self.real_in_position:
|
if not self.real_in_position:
|
||||||
return
|
return None, False
|
||||||
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
|
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
|
||||||
self.real_side_b, self.real_amount_a, self.real_amount_b,
|
self.real_side_b, self.real_amount_a, self.real_amount_b,
|
||||||
label=self.worker_id)
|
label=self.worker_id)
|
||||||
@@ -264,6 +272,7 @@ class PairsWorker:
|
|||||||
self.real_notional_a = self.real_notional_b = 0.0
|
self.real_notional_a = self.real_notional_b = 0.0
|
||||||
self.real_entry_fee = 0.0
|
self.real_entry_fee = 0.0
|
||||||
self._save_state()
|
self._save_state()
|
||||||
|
return real_pnl, bool(pf.verified or pf.leg_a.verified or pf.leg_b.verified)
|
||||||
|
|
||||||
def _close(self, ca: float, cb: float, z: float, reason: str):
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
@@ -273,9 +282,17 @@ class PairsWorker:
|
|||||||
gross = (ret_a - ret_b) * self.direction * self.leverage
|
gross = (ret_a - ret_b) * self.direction * self.leverage
|
||||||
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
||||||
net = gross - fee
|
net = gross - fee
|
||||||
pnl = self.capital * self.position_size * net
|
sim_pnl = self.capital * self.position_size * net
|
||||||
|
|
||||||
|
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger (come StrategyWorker)
|
||||||
|
real_pnl, real_applied = (None, False)
|
||||||
|
if self.execution_enabled:
|
||||||
|
real_pnl, real_applied = self._real_close_pair(ca, cb, reason, sim_pnl)
|
||||||
|
use_real = self.real_truth and real_applied
|
||||||
|
pnl = real_pnl if use_real else sim_pnl
|
||||||
|
|
||||||
self.capital = max(self.capital + pnl, 0.0)
|
self.capital = max(self.capital + pnl, 0.0)
|
||||||
is_win = net > 0
|
is_win = pnl > 0
|
||||||
self.total_trades += 1
|
self.total_trades += 1
|
||||||
self.total_wins += is_win
|
self.total_wins += is_win
|
||||||
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
@@ -284,9 +301,12 @@ class PairsWorker:
|
|||||||
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
|
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
|
||||||
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
||||||
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
||||||
|
if self.real_truth:
|
||||||
|
data["pnl_source"] = "real" if use_real else "sim_fallback"
|
||||||
|
data["sim_pnl"] = round(sim_pnl, 2)
|
||||||
|
if real_pnl is not None:
|
||||||
|
data["real_pnl"] = round(real_pnl, 4)
|
||||||
self._log("CLOSE", data); self._notify("CLOSED", data)
|
self._log("CLOSE", data); self._notify("CLOSED", data)
|
||||||
if self.execution_enabled:
|
|
||||||
self._real_close_pair(ca, cb, reason, pnl)
|
|
||||||
self.in_position = False
|
self.in_position = False
|
||||||
self.direction = 0
|
self.direction = 0
|
||||||
self.entry_a = self.entry_b = self.entry_z = 0.0
|
self.entry_a = self.entry_b = self.entry_z = 0.0
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class StrategyWorker:
|
|||||||
data_dir: Path = Path("data/paper_trades"),
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
executor: ExecutionClient | None = None,
|
executor: ExecutionClient | None = None,
|
||||||
exec_instrument: str | None = None,
|
exec_instrument: str | None = None,
|
||||||
|
real_truth: bool = False,
|
||||||
):
|
):
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.asset = asset
|
self.asset = asset
|
||||||
@@ -53,6 +54,11 @@ class StrategyWorker:
|
|||||||
self.executor = executor
|
self.executor = executor
|
||||||
self.exec_instrument = exec_instrument
|
self.exec_instrument = exec_instrument
|
||||||
self.execution_enabled = bool(executor and exec_instrument)
|
self.execution_enabled = bool(executor and exec_instrument)
|
||||||
|
# REAL-TRUTH (2026-06-10): il ledger che guida il portafoglio (`capital`) si
|
||||||
|
# aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta solo
|
||||||
|
# diagnostica nel log CLOSE. Fallback al sim SOLO se il trade reale non e'
|
||||||
|
# mai esistito/fillato (REAL_OPEN_FAIL, fill zero) — flag pnl_source nel log.
|
||||||
|
self.real_truth = bool(real_truth and self.execution_enabled)
|
||||||
self.real_capital = capital
|
self.real_capital = capital
|
||||||
self.real_in_position = False
|
self.real_in_position = False
|
||||||
self.real_side = "" # "buy" | "sell" dell'apertura reale
|
self.real_side = "" # "buy" | "sell" dell'apertura reale
|
||||||
@@ -316,7 +322,8 @@ class StrategyWorker:
|
|||||||
else:
|
else:
|
||||||
self._log("REAL_DSL_FAIL", {**data, "note": rest.notes})
|
self._log("REAL_DSL_FAIL", {**data, "note": rest.notes})
|
||||||
|
|
||||||
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float):
|
def _real_close(self, sim_exit: float, reason: str,
|
||||||
|
sim_pnl: float) -> tuple[float | None, bool]:
|
||||||
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.
|
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.
|
||||||
|
|
||||||
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
|
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
|
||||||
@@ -324,9 +331,13 @@ class StrategyWorker:
|
|||||||
legge i fill reali dal trade history per order_id; solo la quota residua
|
legge i fill reali dal trade history per order_id; solo la quota residua
|
||||||
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
|
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
|
||||||
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
|
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
|
||||||
non al poll post-rimbalzo."""
|
non al poll post-rimbalzo.
|
||||||
|
|
||||||
|
Ritorna (real_pnl, applied): applied=True se il PnL reale e' basato su
|
||||||
|
fill effettivi (o chiusura verificata) e puo' fare da verita' del ledger
|
||||||
|
in modalita' real-truth; False = nessuna posizione/fill reale."""
|
||||||
if not self.real_in_position:
|
if not self.real_in_position:
|
||||||
return
|
return None, False
|
||||||
from src.live.execution import contract_spec
|
from src.live.execution import contract_spec
|
||||||
step = contract_spec(self.exec_instrument)["step"]
|
step = contract_spec(self.exec_instrument)["step"]
|
||||||
|
|
||||||
@@ -434,6 +445,8 @@ class StrategyWorker:
|
|||||||
self.real_order_id = ""
|
self.real_order_id = ""
|
||||||
self.real_tp_order_id = ""
|
self.real_tp_order_id = ""
|
||||||
self.real_dsl_order_id = ""
|
self.real_dsl_order_id = ""
|
||||||
|
# applied: fill reali presenti (parts) o chiusura comunque verificata
|
||||||
|
return real_pnl, bool(parts) or verified
|
||||||
|
|
||||||
def _close_position(self, current_price: float, reason: str):
|
def _close_position(self, current_price: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
@@ -442,9 +455,17 @@ class StrategyWorker:
|
|||||||
price_change = (current_price - self.entry_price) / self.entry_price
|
price_change = (current_price - self.entry_price) / self.entry_price
|
||||||
trade_return = price_change * self.direction
|
trade_return = price_change * self.direction
|
||||||
net = trade_return * self.leverage - self.fee_rt * self.leverage
|
net = trade_return * self.leverage - self.fee_rt * self.leverage
|
||||||
pnl = self.capital * self.position_size * net
|
sim_pnl = self.capital * self.position_size * net
|
||||||
|
|
||||||
is_win = net > 0 # win = profitto NETTO dopo fee (non il lordo trade_return)
|
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger; se i fill reali
|
||||||
|
# esistono il loro PnL (fee reali incluse) e' la verita' del capitale.
|
||||||
|
real_pnl, real_applied = (None, False)
|
||||||
|
if self.execution_enabled:
|
||||||
|
real_pnl, real_applied = self._real_close(current_price, reason, sim_pnl)
|
||||||
|
use_real = self.real_truth and real_applied
|
||||||
|
pnl = real_pnl if use_real else sim_pnl
|
||||||
|
|
||||||
|
is_win = pnl > 0 # win = profitto NETTO dopo fee (reali se real-truth)
|
||||||
self.capital += pnl
|
self.capital += pnl
|
||||||
self.capital = max(self.capital, 0)
|
self.capital = max(self.capital, 0)
|
||||||
self.total_trades += 1
|
self.total_trades += 1
|
||||||
@@ -466,12 +487,15 @@ class StrategyWorker:
|
|||||||
"total_trades": self.total_trades,
|
"total_trades": self.total_trades,
|
||||||
"accuracy": round(accuracy, 1),
|
"accuracy": round(accuracy, 1),
|
||||||
}
|
}
|
||||||
|
if self.real_truth:
|
||||||
|
# diagnostica: sorgente del PnL applicato + sim a confronto
|
||||||
|
trade_data["pnl_source"] = "real" if use_real else "sim_fallback"
|
||||||
|
trade_data["sim_pnl"] = round(sim_pnl, 2)
|
||||||
|
if real_pnl is not None:
|
||||||
|
trade_data["real_pnl"] = round(real_pnl, 4)
|
||||||
self._log("CLOSE", trade_data)
|
self._log("CLOSE", trade_data)
|
||||||
self._notify("CLOSED", trade_data)
|
self._notify("CLOSED", trade_data)
|
||||||
|
|
||||||
if self.execution_enabled:
|
|
||||||
self._real_close(current_price, reason, pnl)
|
|
||||||
|
|
||||||
self.in_position = False
|
self.in_position = False
|
||||||
self.direction = 0
|
self.direction = 0
|
||||||
self.entry_price = 0
|
self.entry_price = 0
|
||||||
|
|||||||
+13
-5
@@ -57,18 +57,21 @@ def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float],
|
|||||||
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
||||||
data_dir: Path = DATA_DIR, position_size: float = 0.15,
|
data_dir: Path = DATA_DIR, position_size: float = 0.15,
|
||||||
executor=None, exec_instrument: str | None = None,
|
executor=None, exec_instrument: str | None = None,
|
||||||
pairs_executor=None, exec_instruments: dict | None = None):
|
pairs_executor=None, exec_instruments: dict | None = None,
|
||||||
|
real_truth: bool = False):
|
||||||
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
|
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
|
||||||
|
|
||||||
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
|
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
|
||||||
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
|
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
|
||||||
(esecuzione reale a 2 gambe)."""
|
(esecuzione reale a 2 gambe). real_truth: il ledger `capital` si aggiorna col PnL
|
||||||
|
dei FILL REALI (sim ridotto a diagnostica) — inerte senza executor."""
|
||||||
if spec.kind == "pairs":
|
if spec.kind == "pairs":
|
||||||
return PairsWorker(
|
return PairsWorker(
|
||||||
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
||||||
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
||||||
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
||||||
executor=pairs_executor, exec_instruments=exec_instruments,
|
executor=pairs_executor, exec_instruments=exec_instruments,
|
||||||
|
real_truth=real_truth,
|
||||||
)
|
)
|
||||||
if spec.kind == "basket":
|
if spec.kind == "basket":
|
||||||
pr = spec.params
|
pr = spec.params
|
||||||
@@ -110,7 +113,7 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
|||||||
return StrategyWorker(
|
return StrategyWorker(
|
||||||
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
||||||
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
|
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
|
||||||
executor=executor, exec_instrument=exec_instrument,
|
executor=executor, exec_instrument=exec_instrument, real_truth=real_truth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -277,6 +280,9 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
exec_sleeves = set(_exec_cfg.get("sleeves", []))
|
exec_sleeves = set(_exec_cfg.get("sleeves", []))
|
||||||
exec_instr = _exec_cfg.get("instruments", {}) or {}
|
exec_instr = _exec_cfg.get("instruments", {}) or {}
|
||||||
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
|
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
|
||||||
|
# REAL-TRUTH (2026-06-10): il capitale degli sleeve eseguiti si aggiorna col PnL
|
||||||
|
# dei fill reali (sim = diagnostica). Default False: va acceso esplicitamente in yml.
|
||||||
|
real_truth = bool(_exec_cfg.get("real_truth", False))
|
||||||
executor = None
|
executor = None
|
||||||
pairs_executor = None
|
pairs_executor = None
|
||||||
if exec_enabled:
|
if exec_enabled:
|
||||||
@@ -285,7 +291,8 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
|
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
|
||||||
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
|
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
|
||||||
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
|
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
|
||||||
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct}")
|
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct} "
|
||||||
|
f"real_truth={real_truth}")
|
||||||
if pairs_exec_enabled:
|
if pairs_exec_enabled:
|
||||||
from src.live.execution import PairsExecutionClient
|
from src.live.execution import PairsExecutionClient
|
||||||
pairs_executor = PairsExecutionClient(leg=executor)
|
pairs_executor = PairsExecutionClient(leg=executor)
|
||||||
@@ -318,7 +325,8 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
|
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
|
||||||
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")),
|
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")),
|
||||||
executor=ex, exec_instrument=inst,
|
executor=ex, exec_instrument=inst,
|
||||||
pairs_executor=pex, exec_instruments=pinst)
|
pairs_executor=pex, exec_instruments=pinst,
|
||||||
|
real_truth=real_truth)
|
||||||
if ps_family:
|
if ps_family:
|
||||||
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
|
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""REAL-TRUTH (2026-06-10): col flag attivo il ledger `capital` dei worker eseguiti
|
||||||
|
si aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta diagnostica nel
|
||||||
|
log CLOSE (pnl_source/sim_pnl/real_pnl). Fallback dichiarato al sim solo se il trade
|
||||||
|
reale non e' mai esistito/fillato. Executor finti, nessuna rete."""
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from src.live.execution import Fill, PairFill
|
||||||
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- helpers ----------------
|
||||||
|
|
||||||
|
class FakeExec:
|
||||||
|
"""Single-leg: open filla a `open_px`, close a `close_px` (fee fisse)."""
|
||||||
|
verify_polls = 1
|
||||||
|
verify_sleep = 0.0
|
||||||
|
disaster_sl_pct = None
|
||||||
|
|
||||||
|
def __init__(self, open_px=100.0, close_px=100.0, close_verified=True):
|
||||||
|
self.open_px = open_px
|
||||||
|
self.close_px = close_px
|
||||||
|
self.close_verified = close_verified
|
||||||
|
|
||||||
|
def open(self, instrument, side, notional, label=None):
|
||||||
|
amt = round(notional / self.open_px, 6)
|
||||||
|
return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05,
|
||||||
|
"oid-open", "filled", True)
|
||||||
|
|
||||||
|
def place_tp_limit(self, *a, **k):
|
||||||
|
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
|
||||||
|
|
||||||
|
def place_disaster_sl(self, *a, **k):
|
||||||
|
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
|
||||||
|
|
||||||
|
def cancel_order(self, oid):
|
||||||
|
return {"state": "cancelled"}
|
||||||
|
|
||||||
|
def resting_fills(self, instrument, oid):
|
||||||
|
return 0.0, None, 0.0
|
||||||
|
|
||||||
|
def close_amount(self, instrument, side, amount, label=None):
|
||||||
|
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
|
||||||
|
self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified)
|
||||||
|
|
||||||
|
|
||||||
|
def _sw(tmp_path, fake, real_truth=True):
|
||||||
|
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
|
||||||
|
asset="BTC", tf="1h", capital=100.0, position_size=0.5,
|
||||||
|
leverage=2.0, data_dir=tmp_path, executor=fake,
|
||||||
|
exec_instrument="BTC_USDC-PERPETUAL", real_truth=real_truth)
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def _last_close(w):
|
||||||
|
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
|
||||||
|
return [r for r in rows if r.get("event") == "CLOSE"][-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _open_sim(w, price=100.0):
|
||||||
|
from src.strategies.base import Signal
|
||||||
|
w._open_position(Signal(idx=0, direction=1, entry_price=price,
|
||||||
|
metadata={"max_bars": 12}), price)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- StrategyWorker ----------------
|
||||||
|
|
||||||
|
def test_capital_updates_from_real_fills(tmp_path):
|
||||||
|
"""Fill reali 100->105: capital += real_pnl (gross sul notional reale - fee reali),
|
||||||
|
NON il sim pnl."""
|
||||||
|
fake = FakeExec(open_px=100.0, close_px=105.0)
|
||||||
|
w = _sw(tmp_path, fake)
|
||||||
|
_open_sim(w, 100.0)
|
||||||
|
assert w.real_in_position
|
||||||
|
notional = w.real_entry_notional # = capital*ps*lev = 100
|
||||||
|
w._close_position(105.0, "time_limit")
|
||||||
|
real_pnl = 0.05 * notional - 0.10 # +5% su notional - fee 2x0.05
|
||||||
|
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
|
||||||
|
c = _last_close(w)
|
||||||
|
assert c["pnl_source"] == "real"
|
||||||
|
assert abs(c["real_pnl"] - real_pnl) < 1e-3
|
||||||
|
assert c["win"] is True and w.total_wins == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_loss_counts_as_loss_even_if_sim_wins(tmp_path):
|
||||||
|
"""Divergenza sim/reale: il sim vede +5% (exit sim 105) ma il fill reale e' 99
|
||||||
|
-> in real-truth il trade e' LOSS e il capitale scende."""
|
||||||
|
fake = FakeExec(open_px=100.0, close_px=99.0)
|
||||||
|
w = _sw(tmp_path, fake)
|
||||||
|
_open_sim(w, 100.0)
|
||||||
|
w._close_position(105.0, "take_profit") # sim esce a 105 (win sim)
|
||||||
|
c = _last_close(w)
|
||||||
|
assert c["pnl_source"] == "real"
|
||||||
|
assert c["real_pnl"] < 0 and c["win"] is False
|
||||||
|
assert w.capital < 100.0
|
||||||
|
assert c["sim_pnl"] > 0 # il sim avrebbe bookato un win
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_sim_when_real_open_failed(tmp_path):
|
||||||
|
"""REAL_OPEN_FAIL (nessuna posizione reale): il ledger usa il sim, con flag."""
|
||||||
|
class FailOpen(FakeExec):
|
||||||
|
def open(self, instrument, side, notional, label=None):
|
||||||
|
return Fill(instrument, side, notional, 0.0, None, 0.0, 0.0,
|
||||||
|
None, "error", False, notes="boom")
|
||||||
|
w = _sw(tmp_path, FailOpen())
|
||||||
|
_open_sim(w, 100.0)
|
||||||
|
assert not w.real_in_position
|
||||||
|
w._close_position(105.0, "time_limit")
|
||||||
|
c = _last_close(w)
|
||||||
|
assert c["pnl_source"] == "sim_fallback"
|
||||||
|
assert c["pnl"] == c["sim_pnl"]
|
||||||
|
assert w.capital > 100.0 # sim pnl applicato
|
||||||
|
|
||||||
|
|
||||||
|
def test_shadow_mode_unchanged_without_flag(tmp_path):
|
||||||
|
"""real_truth=False (default storico): capital segue il SIM, real_capital il reale."""
|
||||||
|
fake = FakeExec(open_px=100.0, close_px=99.0)
|
||||||
|
w = _sw(tmp_path, fake, real_truth=False)
|
||||||
|
_open_sim(w, 100.0)
|
||||||
|
w._close_position(105.0, "take_profit")
|
||||||
|
c = _last_close(w)
|
||||||
|
assert "pnl_source" not in c
|
||||||
|
assert w.capital > 100.0 # sim win applicato al ledger
|
||||||
|
assert w.real_capital < 100.0 # reale in perdita, separato
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- PairsWorker ----------------
|
||||||
|
|
||||||
|
class FakePairsExec:
|
||||||
|
def __init__(self, open_a=100.0, open_b=50.0, close_a=102.0, close_b=50.0):
|
||||||
|
self.px = dict(open_a=open_a, open_b=open_b, close_a=close_a, close_b=close_b)
|
||||||
|
|
||||||
|
def _fill(self, inst, side, amount, px):
|
||||||
|
return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True)
|
||||||
|
|
||||||
|
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
|
||||||
|
sa = "buy" if direction == 1 else "sell"
|
||||||
|
sb = "sell" if direction == 1 else "buy"
|
||||||
|
return PairFill(True,
|
||||||
|
self._fill(inst_a, sa, notional / self.px["open_a"], self.px["open_a"]),
|
||||||
|
self._fill(inst_b, sb, notional / self.px["open_b"], self.px["open_b"]))
|
||||||
|
|
||||||
|
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
|
||||||
|
return PairFill(True,
|
||||||
|
self._fill(inst_a, "sell", amount_a, self.px["close_a"]),
|
||||||
|
self._fill(inst_b, "buy", amount_b, self.px["close_b"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _pw(tmp_path, fake, real_truth=True):
|
||||||
|
w = PairsWorker(asset_a="ETH", asset_b="BTC", tf="1h", capital=100.0,
|
||||||
|
position_size=0.2, leverage=2.0, data_dir=tmp_path,
|
||||||
|
executor=fake, exec_instruments={"ETH": "ETH_USDC-PERPETUAL",
|
||||||
|
"BTC": "BTC_USDC-PERPETUAL"},
|
||||||
|
real_truth=real_truth)
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_capital_updates_from_real_fills(tmp_path):
|
||||||
|
"""Long ratio, gamba A +2% reale: capital += real_pnl (2 gambe, 4 fee da 0.02)."""
|
||||||
|
fake = FakePairsExec(open_a=100.0, close_a=102.0)
|
||||||
|
w = _pw(tmp_path, fake)
|
||||||
|
w._open(1, 100.0, 50.0, -2.1)
|
||||||
|
assert w.real_in_position
|
||||||
|
na = w.real_notional_a
|
||||||
|
w._close(102.0, 50.0, 0.1, "mean_revert")
|
||||||
|
real_pnl = 0.02 * na - 4 * 0.02 # +2% gamba A - fee 4 lati
|
||||||
|
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
|
||||||
|
c = json.loads([l for l in w.trades_path.read_text().splitlines()
|
||||||
|
if '"CLOSE"' in l][-1])
|
||||||
|
assert c["pnl_source"] == "real"
|
||||||
|
assert w.total_trades == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_fallback_sim_on_leg_fail(tmp_path):
|
||||||
|
"""Apertura reale fallita (leg-risk unwound): chiusura su ledger sim con flag."""
|
||||||
|
class FailOpen(FakePairsExec):
|
||||||
|
def open_pair(self, *a, **k):
|
||||||
|
bad = Fill("x", "buy", 0.0, 0.0, None, 0.0, 0.0, None, "error", False)
|
||||||
|
return PairFill(False, bad, bad, unwound=False, notes="leg-fail")
|
||||||
|
w = _pw(tmp_path, FailOpen())
|
||||||
|
w._open(1, 100.0, 50.0, -2.1)
|
||||||
|
assert not w.real_in_position
|
||||||
|
w._close(102.0, 50.0, 0.1, "mean_revert")
|
||||||
|
c = json.loads([l for l in w.trades_path.read_text().splitlines()
|
||||||
|
if '"CLOSE"' in l][-1])
|
||||||
|
assert c["pnl_source"] == "sim_fallback"
|
||||||
|
assert c["pnl"] == c["sim_pnl"]
|
||||||
Reference in New Issue
Block a user