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:
@@ -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.
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user