feat(live): TP reale = LIMIT reduce-only al livello (fix +235bps slippage exit)

All'apertura reale il worker piazza un limit reduce-only al TP della strategia
(quota del SOLO worker, prezzo quantizzato al tick); alla chiusura sim cancella
il resting, riconcilia i fill (anche parziali) via get_trade_history per
order_id e chiude a market solo il residuo. real_tp_order_id persistito
(resume-safe). SL resta market-on-poll (deliberato: trigger Deribit = nuovo
order_id al trigger, fill non verificabile; e il rimbalzo sul SL lavora a
favore). Smoke testnet 2 scenari OK (resting+cancel / fill immediato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-04 19:24:07 +00:00
parent e8075b6292
commit abd396fa54
6 changed files with 264 additions and 34 deletions
+1
View File
@@ -316,6 +316,7 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra). - **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco. - **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
- **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml``overrides.execution {enabled, sleeves:[MR01,MR02,MR07], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}`; pairs/rotation/tsmom/shape/dip restano **simulati**. **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura. - **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml``overrides.execution {enabled, sleeves:[MR01,MR02,MR07], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}`; pairs/rotation/tsmom/shape/dip restano **simulati**. **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.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
@@ -37,3 +37,56 @@ Dettagli da considerare:
- i 3 fade BTC condividono lo strumento (posizioni nettate per conto): il limit - i 3 fade BTC condividono lo strumento (posizioni nettate per conto): il limit
reduce-only deve coprire la SOLA quota del worker, come già fa `close_amount`; reduce-only deve coprire la SOLA quota del worker, come già fa `close_amount`;
- verificare il fill del resting order via `get_trade_history` (order_id), come per i market. - verificare il fill del resting order via `get_trade_history` (order_id), come per i market.
## IMPLEMENTATO (sera): LIMIT reduce-only al TP
**Flusso nuovo.** A ogni `REAL_OPEN` verificato il worker piazza subito un
**limit reduce-only al livello TP** (lato opposto, stesso `amount` della SOLA
quota del worker — strumento condiviso fra i 3 fade per coin) → evento
`REAL_TP_RESTING` (o `REAL_TP_FAIL` → si resta sul vecchio market-on-poll).
Alla chiusura sim, qualunque sia la ragione, `_real_close` ora:
1. **cancella** il resting (innocuo se già fillato — e dopo il cancel nessun
fill può più arrivare, niente race);
2. **riconcilia i fill** (anche parziali) dal `get_trade_history` per order_id
(stessa fonte autorevole dei market), con retry se il cancel risulta
"già fillato" ma la history è in ritardo;
3. chiude a **market reduce-only solo la quota residua** (exit non-TP:
stop_loss/time_limit, o TP non raggiunto sullo strumento d'esecuzione);
4. ledger reale su **prezzo d'uscita combinato** (media pesata fill-TP +
market) e fee sommate. `REAL_CLOSE` logga `tp_order_id`,
`tp_filled_amount`, `market_amount`.
**Dettagli del TODO coperti:**
- `max_bars`/`stop_loss` → il cancel è il passo 1 di ogni chiusura;
- restart → `real_tp_order_id` è **persistito** in `status.json`: al resume il
resting resta in book e la prima chiusura lo riconcilia normalmente;
- quota del worker → il limit usa lo stesso `amount` dell'apertura (mai
`close_position`); il residuo è ri-quantizzato allo step (Decimal) dentro
`close_amount` (niente artefatti float tipo `0.072-0.024`);
- prezzi limit quantizzati al **tick** dello strumento (`quantize_price`,
BTC_USDC 0.5 / ETH_USDC 0.05 — Deribit rifiuta prezzi off-tick).
**SL: niente stop-market, decisione deliberata.** (a) Su Deribit un trigger
order, quando scatta, genera l'ordine eseguito con un **nuovo order_id** → il
fill non sarebbe verificabile via `get_trade_history` per order_id (il
requisito di verifica); (b) la misura di stamattina mostra che sul SL il
market-on-poll lavora **a favore** (il rimbalzo riduce la perdita). Lo SL resta
quindi market-on-poll.
**Bonus fee.** Quando il TP filla da resting il fill è **maker** (~0% su
Deribit) invece di taker 0.05% — il reale farà meglio dell'assunto 0.10% RT
sulle uscite TP.
**Verifica.** `live_shadow_smoke.py` esteso ai due percorsi e passato su
testnet: (A) resting in book → exit `time_limit` → cancel + market fallback,
conto flat; (B) TP già crossato → fill immediato del limit, chiusura
riconciliata dalla history **senza ordine market** (`market_amount=0`),
prezzo persino migliore del TP (+48 bps, taker sul cross). 54 test pytest OK.
**Caveat residuo onesto.** Se il processo muore DOPO il fill del resting e
PRIMA della chiusura sim, il worker al resume riconcilia al prossimo exit sim
(corretto); ma nell'intervallo la quota reale è già chiusa mentre il sim la
crede aperta — finestra breve e solo contabile. Inoltre il TP del sim è sul
feed (`BTC-PERPETUAL` inverse) mentre il limit vive sul lineare USDC: la base
fra i due può far fillare/non fillare il limit a cavallo del livello — il
fallback market copre il caso.
+34 -11
View File
@@ -1,8 +1,13 @@
"""Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi). """Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi).
Apre e chiude la quota di UN worker fade come farebbe il runner: Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i
_open_position (sim + REAL_OPEN reale su BTC_USDC) → _close_position (sim + DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
REAL_CLOSE reduce-only) → controlla che real_capital sia aggiornato dal fill reale.
A) TP lontano → REAL_TP_RESTING in book; exit sim non-TP → cancel + market
reduce-only di fallback (REAL_CLOSE con tp_filled_amount=0);
B) TP gia' oltre il prezzo → il limit crossa e filla SUBITO; la chiusura
riconcilia il fill dal trade history (order_id) SENZA ordine market
(REAL_CLOSE con market_amount=0).
Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0. Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0.
@@ -40,27 +45,45 @@ def main() -> None:
) )
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}") print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
# OPEN long (sim + reale) # --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market ---
sig = Signal(idx=0, direction=1, entry_price=price, metadata={}) print("\n[A] TP lontano (resting) → exit time_limit → cancel + market fallback")
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 1.05, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price) w._open_position(sig, price)
print(f" real_in_position={w.real_in_position} side={w.real_side} " print(f" real_in_position={w.real_in_position} side={w.real_side} "
f"amount={w.real_amount} entry={w.real_entry_price} " f"amount={w.real_amount} entry={w.real_entry_price} "
f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}") f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}")
assert w.real_in_position, "OPEN reale non verificato" assert w.real_in_position, "OPEN reale non verificato"
print(f" TP resting order_id={w.real_tp_order_id!r}")
assert w.real_tp_order_id, "LIMIT reduce-only al TP non piazzato"
# CLOSE (sim + reale reduce-only) a un prezzo leggermente diverso
exit_price = (w.entry_price or price) * 1.001
cap_before = w.real_capital cap_before = w.real_capital
w._close_position(exit_price, "smoke_close") w._close_position((w.entry_price or price) * 1.001, "time_limit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} " print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}") f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa" assert not w.real_in_position, "posizione reale non chiusa"
assert not w.real_tp_order_id, "order_id TP non resettato dopo la chiusura"
# --- Scenario B: TP gia' oltre il prezzo → il limit crossa e filla subito ---
print("\n[B] TP gia' crossato (fill immediato del limit) → close riconcilia da history")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 0.995, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price)
assert w.real_in_position, "OPEN reale non verificato (B)"
assert w.real_tp_order_id, "LIMIT TP non piazzato (B)"
cap_before = w.real_capital
w._close_position(w.tp, "take_profit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa (B)"
# verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker) # verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker)
pos = ex._position_size(instrument) pos = ex._position_size(instrument)
print(f" posizione netta {instrument}: {pos}") print(f"\n posizione netta {instrument}: {pos}")
print("✓ catena shadow OK — ordine reale aperto, verificato, chiuso reduce-only, " print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, "
"fee reali nel ledger reale") "B: fill immediato riconciliato), fee reali nel ledger reale")
if __name__ == "__main__": if __name__ == "__main__":
+6
View File
@@ -116,6 +116,12 @@ class CerberoClient:
payload["reduce_only"] = True payload["reduce_only"] = True
return self._post("/mcp-deribit/tools/place_order", payload) return self._post("/mcp-deribit/tools/place_order", payload)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting (es. limit reduce-only al TP). Ritorna
{order_id, state}; state='error' se l'ordine non e' piu' open (gia'
fillato/cancellato) — il chiamante riconcilia via get_trade_history."""
return self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})
def close_position(self, instrument: str) -> dict: def close_position(self, instrument: str) -> dict:
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument}) return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
+88 -14
View File
@@ -27,10 +27,10 @@ from src.live.cerbero_client import CerberoClient
# LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001); # LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001);
# il notional USD si converte col prezzo. Fee/settle in USDC. # il notional USD si converte col prezzo. Fee/settle in USDC.
_CONTRACT: dict[str, dict[str, Any]] = { _CONTRACT: dict[str, dict[str, Any]] = {
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0}, "BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5},
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0}, "ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05},
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"}, "BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC"},
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "settle": "USDC"}, "ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC"},
} }
@@ -54,6 +54,15 @@ def _quantize_step(value: float, step: float, mn: float) -> float:
return float(max(n_steps * Decimal(str(step)), Decimal(str(mn)))) return float(max(n_steps * Decimal(str(step)), Decimal(str(mn))))
def quantize_price(instrument: str, price: float) -> float:
"""Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick)."""
tick = contract_spec(instrument).get("tick")
if not tick or price <= 0:
return price
return float(round(price / tick) * Decimal(str(tick)))
def notional_to_amount(instrument: str, notional_usd: float, def notional_to_amount(instrument: str, notional_usd: float,
price: float | None = None) -> float: price: float | None = None) -> float:
"""Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo. """Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo.
@@ -157,17 +166,20 @@ class ExecutionClient:
def _submit(self, instrument: str, side: str, amount: float, def _submit(self, instrument: str, side: str, amount: float,
requested_notional: float, reduce_only: bool, requested_notional: float, reduce_only: bool,
label: str | None) -> Fill: label: str | None, order_type: str = "market",
"""Market order REALE + parsing del fill. Verifica per-worker basata sul price: float | None = None) -> Fill:
TRADE (order_id/trades), non sulla size netta — lo strumento e' condiviso """Ordine REALE (market o limit resting) + parsing del fill. Verifica
fra piu' worker e la posizione su Deribit e' aggregata per conto.""" per-worker basata sul TRADE (order_id/trades), non sulla size netta — lo
strumento e' condiviso fra piu' worker e la posizione su Deribit e'
aggregata per conto. Per i limit la verifica e' l'ACCETTAZIONE in book
(state 'open', o 'filled' se crossa subito)."""
spec = contract_spec(instrument) spec = contract_spec(instrument)
if amount <= 0: if amount <= 0:
return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0, return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0,
None, None, False, notes="notional sotto il minimo contratto") None, None, False, notes="notional sotto il minimo contratto")
resp = self.client.place_order(instrument, side, amount, order_type="market", resp = self.client.place_order(instrument, side, amount, order_type=order_type,
label=label, reduce_only=reduce_only) price=price, label=label, reduce_only=reduce_only)
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0, return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0,
None, "error", False, raw=resp if isinstance(resp, dict) else {}, None, "error", False, raw=resp if isinstance(resp, dict) else {},
@@ -181,8 +193,11 @@ class ExecutionClient:
# fee reale dai trade del fill (coin di settlement) # fee reale dai trade del fill (coin di settlement)
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
# riconciliazione su trade history per order_id (fonte autorevole) # riconciliazione su trade history per order_id (fonte autorevole)
th = self._trade_by_order(instrument, order_id) # inutile per un limit ancora in book senza fill
th = None
if order_type == "market" or trades:
th = self._trade_by_order(instrument, order_id)
if fee_coin == 0 and th and th.get("fee") is not None: if fee_coin == 0 and th and th.get("fee") is not None:
fee_coin = float(th["fee"]) fee_coin = float(th["fee"])
if fill_price is None and th: if fill_price is None and th:
@@ -191,8 +206,12 @@ class ExecutionClient:
fee_usd = fee_coin if spec.get("linear") else ( fee_usd = fee_coin if spec.get("linear") else (
fee_coin * fill_price if (fee_coin and fill_price) else 0.0) fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
# VERIFICA esecuzione = ordine filled E fill riscontrato (trades o trade history) # VERIFICA: market = ordine filled E fill riscontrato (trades o history);
verified = (state == "filled") and (bool(trades) or th is not None) # limit = accettato in book ('open') o gia' eseguito ('filled')
if order_type == "market":
verified = (state == "filled") and (bool(trades) or th is not None)
else:
verified = state in ("open", "filled")
return Fill(instrument, side, requested_notional, amount, fill_price, return Fill(instrument, side, requested_notional, amount, fill_price,
fee_coin, fee_usd, order_id, state, verified, raw=resp, fee_coin, fee_usd, order_id, state, verified, raw=resp,
notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})")
@@ -209,10 +228,65 @@ class ExecutionClient:
"""Chiude SOLO la quota del worker: market reduce_only di lato opposto, """Chiude SOLO la quota del worker: market reduce_only di lato opposto,
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche
le quote degli altri worker sullo stesso strumento).""" le quote degli altri worker sullo stesso strumento)."""
spec = contract_spec(instrument)
# quantizza difensivamente: il chiamante puo' passare un residuo
# (amount aperto fill parziale del TP) con artefatti float
amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0
opp = "sell" if entry_side == "buy" else "buy" opp = "sell" if entry_side == "buy" else "buy"
return self._submit(instrument, opp, amount, 0.0, return self._submit(instrument, opp, amount, 0.0,
reduce_only=True, label=label) reduce_only=True, label=label)
def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill:
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata):
il fill reale replica l'assunzione intrabar del backtest (exit AL livello)
invece del market-on-poll post-rimbalzo (+235 bps misurati il 2026-06-04).
Copre la SOLA quota del worker (stesso `amount` dell'apertura) — lo
strumento e' condiviso fra piu' worker. NB: se il prezzo e' gia' oltre il
TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
valle (resting_fills) lo gestisce come un fill normale."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, tp_price)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/tp non validi")
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="limit", price=px)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting. {'state': 'cancelled'} su successo;
'error' se l'ordine non e' piu' open (es. gia' fillato) — NON fatale:
il chiamante riconcilia i fill via resting_fills (trade history)."""
if not order_id:
return {"state": "error", "error": "no order_id"}
try:
return self.client.cancel_order(order_id)
except Exception as exc:
return {"state": "error", "error": str(exc)}
def resting_fills(self, instrument: str,
order_id: str) -> tuple[float, float | None, float]:
"""Fill (anche parziali) di un ordine resting, riconciliati dal trade
history per order_id (fonte autorevole, come per i market). Ritorna
(amount_fillato, prezzo_medio, fee_usd)."""
if not order_id:
return 0.0, None, 0.0
spec = contract_spec(instrument)
try:
trades = [t for t in self.client.get_trade_history(
limit=100, instrument_name=instrument)
if str(t.get("order_id")) == str(order_id)]
except Exception:
return 0.0, None, 0.0
amt = sum(float(t.get("amount", 0) or 0) for t in trades)
if amt <= 0:
return 0.0, None, 0.0
avg = sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0)
for t in trades) / amt
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
fee_usd = fee_coin if spec.get("linear") else (fee_coin * avg if avg else 0.0)
return amt, avg or None, fee_usd
def close(self, instrument: str, label: str | None = None) -> Fill: def close(self, instrument: str, label: str | None = None) -> Fill:
"""Chiude a mercato la posizione e riverifica che il conto sia flat, """Chiude a mercato la posizione e riverifica che il conto sia flat,
leggendo la fee di chiusura dal trade history.""" leggendo la fee di chiusura dal trade history."""
+82 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -54,6 +55,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0 self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
self.real_order_id = "" self.real_order_id = ""
self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume)
self.real_trades = 0 self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
@@ -116,6 +118,7 @@ class StrategyWorker:
self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0) self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0)
self.real_entry_notional = state.get("real_entry_notional", 0.0) self.real_entry_notional = state.get("real_entry_notional", 0.0)
self.real_order_id = state.get("real_order_id", "") self.real_order_id = state.get("real_order_id", "")
self.real_tp_order_id = state.get("real_tp_order_id", "")
self.real_trades = state.get("real_trades", 0) self.real_trades = state.get("real_trades", 0)
self.real_first_notified = state.get("real_first_notified", False) self.real_first_notified = state.get("real_first_notified", False)
@@ -148,6 +151,7 @@ class StrategyWorker:
"real_entry_fee_usd": self.real_entry_fee_usd, "real_entry_fee_usd": self.real_entry_fee_usd,
"real_entry_notional": self.real_entry_notional, "real_entry_notional": self.real_entry_notional,
"real_order_id": self.real_order_id, "real_order_id": self.real_order_id,
"real_tp_order_id": self.real_tp_order_id,
"real_trades": self.real_trades, "real_trades": self.real_trades,
"real_first_notified": self.real_first_notified, "real_first_notified": self.real_first_notified,
"last_update": datetime.now(timezone.utc).isoformat(), "last_update": datetime.now(timezone.utc).isoformat(),
@@ -234,22 +238,87 @@ class StrategyWorker:
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
self._notify("REAL_EXEC_LIVE", data) self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True self.real_first_notified = True
self._place_real_tp()
else: else:
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes}) self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes}) self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
def _place_real_tp(self):
"""LIMIT reduce-only appoggiato al TP della strategia (fix divergenza
sim/reale 2026-06-04: il market-on-poll usciva post-rimbalzo, +235 bps
sopra il livello TP). Copre la SOLA quota del worker. Se il piazzamento
fallisce si resta sul fallback market-on-poll di _real_close."""
self.real_tp_order_id = ""
if not (self.tp and self.real_amount > 0):
return
rest = self.executor.place_tp_limit(self.exec_instrument, self.real_side,
self.real_amount, self.tp,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"tp": round(self.tp, 2),
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_tp_order_id = rest.order_id
self._log("REAL_TP_RESTING", data)
else:
self._log("REAL_TP_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):
"""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
se gia' fillato — cosi' nessun fill puo' arrivare DOPO la lettura) e
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).
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
non al poll post-rimbalzo."""
if not self.real_in_position: if not self.real_in_position:
return return
fill = self.executor.close_amount(self.exec_instrument, self.real_side, from src.live.execution import contract_spec
self.real_amount, label=self.worker_id) step = contract_spec(self.exec_instrument)["step"]
exit_price = fill.fill_price or sim_exit
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
tp_amt, tp_px, tp_fee = 0.0, None, 0.0
tp_order_id = self.real_tp_order_id
if tp_order_id:
cres = self.executor.cancel_order(tp_order_id)
cancelled = cres.get("state") == "cancelled"
for _ in range(self.executor.verify_polls):
tp_amt, tp_px, tp_fee = self.executor.resting_fills(
self.exec_instrument, tp_order_id)
if tp_amt > 0 or cancelled:
break # cancel pulito = al piu' fill parziali gia' visti
time.sleep(self.executor.verify_sleep)
tp_amt = min(tp_amt, self.real_amount)
if tp_amt > 0 and not tp_px:
tp_px = self.tp or sim_exit # fallback: il limit filla al suo livello
# 2) quota residua → market reduce-only (mai close_position: strumento condiviso)
remainder = self.real_amount - tp_amt
fill = None
if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id)
market_amt = fill.amount if (fill and fill.verified) else 0.0
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
parts = [(a, p) for a, p in ((tp_amt, tp_px),
(market_amt, fill.fill_price if fill else None))
if a > 0 and p]
exit_price = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)
if parts else sim_exit)
exit_fee = tp_fee + (fill.fee_usd if fill else 0.0)
verified = (tp_amt + market_amt) >= self.real_amount - step / 2
rdir = 1 if self.real_side == "buy" else -1 rdir = 1 if self.real_side == "buy" else -1
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \ price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
if self.real_entry_price else 0.0 if self.real_entry_price else 0.0
real_gross = rdir * price_change * self.real_entry_notional real_gross = rdir * price_change * self.real_entry_notional
real_fees = self.real_entry_fee_usd + fill.fee_usd real_fees = self.real_entry_fee_usd + exit_fee
real_pnl = real_gross - real_fees real_pnl = real_gross - real_fees
self.real_capital += real_pnl self.real_capital += real_pnl
self.real_trades += 1 self.real_trades += 1
@@ -258,16 +327,19 @@ class StrategyWorker:
if exit_price and sim_exit else None) if exit_price and sim_exit else None)
self._log("REAL_CLOSE", { self._log("REAL_CLOSE", {
"reason": reason, "reason": reason,
"order_id": fill.order_id, "order_id": fill.order_id if fill else tp_order_id,
"tp_order_id": tp_order_id or None,
"tp_filled_amount": tp_amt,
"market_amount": market_amt,
"sim_exit": round(sim_exit, 2), "sim_exit": round(sim_exit, 2),
"real_fill": fill.fill_price, "real_fill": round(exit_price, 2) if parts else None,
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None, "slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
"entry_fee_usd": round(self.real_entry_fee_usd, 5), "entry_fee_usd": round(self.real_entry_fee_usd, 5),
"exit_fee_usd": round(fill.fee_usd, 5), "exit_fee_usd": round(exit_fee, 5),
"real_pnl_usd": round(real_pnl, 4), "real_pnl_usd": round(real_pnl, 4),
"sim_pnl_usd": round(sim_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4),
"real_capital": round(self.real_capital, 4), "real_capital": round(self.real_capital, 4),
"verified": fill.verified, "verified": verified,
}) })
self.real_in_position = False self.real_in_position = False
@@ -277,6 +349,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0 self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 self.real_entry_notional = 0.0
self.real_order_id = "" self.real_order_id = ""
self.real_tp_order_id = ""
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: