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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Multi-Strategy Paper Trader
|
||||
|
||||
@@ -37,3 +37,56 @@ Dettagli da considerare:
|
||||
- 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`;
|
||||
- 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).
|
||||
|
||||
Apre e chiude la quota di UN worker fade come farebbe il runner:
|
||||
_open_position (sim + REAL_OPEN reale su BTC_USDC) → _close_position (sim +
|
||||
REAL_CLOSE reduce-only) → controlla che real_capital sia aggiornato dal fill reale.
|
||||
Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i
|
||||
DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
|
||||
|
||||
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.
|
||||
|
||||
@@ -40,27 +45,45 @@ def main() -> None:
|
||||
)
|
||||
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
|
||||
|
||||
# OPEN long (sim + reale)
|
||||
sig = Signal(idx=0, direction=1, entry_price=price, metadata={})
|
||||
# --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market ---
|
||||
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)
|
||||
print(f" real_in_position={w.real_in_position} side={w.real_side} "
|
||||
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}")
|
||||
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
|
||||
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} "
|
||||
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_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)
|
||||
pos = ex._position_size(instrument)
|
||||
print(f" posizione netta {instrument}: {pos}")
|
||||
print("✓ catena shadow OK — ordine reale aperto, verificato, chiuso reduce-only, "
|
||||
"fee reali nel ledger reale")
|
||||
print(f"\n posizione netta {instrument}: {pos}")
|
||||
print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, "
|
||||
"B: fill immediato riconciliato), fee reali nel ledger reale")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -116,6 +116,12 @@ class CerberoClient:
|
||||
payload["reduce_only"] = True
|
||||
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:
|
||||
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
|
||||
|
||||
|
||||
+86
-12
@@ -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);
|
||||
# il notional USD si converte col prezzo. Fee/settle in USDC.
|
||||
_CONTRACT: dict[str, dict[str, Any]] = {
|
||||
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0},
|
||||
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0},
|
||||
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"},
|
||||
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "settle": "USDC"},
|
||||
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5},
|
||||
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05},
|
||||
"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, "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))))
|
||||
|
||||
|
||||
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,
|
||||
price: float | None = None) -> float:
|
||||
"""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,
|
||||
requested_notional: float, reduce_only: bool,
|
||||
label: str | None) -> Fill:
|
||||
"""Market order REALE + parsing del fill. Verifica 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."""
|
||||
label: str | None, order_type: str = "market",
|
||||
price: float | None = None) -> Fill:
|
||||
"""Ordine REALE (market o limit resting) + parsing del fill. Verifica
|
||||
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)
|
||||
if amount <= 0:
|
||||
return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0,
|
||||
None, None, False, notes="notional sotto il minimo contratto")
|
||||
|
||||
resp = self.client.place_order(instrument, side, amount, order_type="market",
|
||||
label=label, reduce_only=reduce_only)
|
||||
resp = self.client.place_order(instrument, side, amount, order_type=order_type,
|
||||
price=price, label=label, reduce_only=reduce_only)
|
||||
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,
|
||||
None, "error", False, raw=resp if isinstance(resp, dict) else {},
|
||||
@@ -181,7 +193,10 @@ class ExecutionClient:
|
||||
|
||||
# fee reale dai trade del fill (coin di settlement)
|
||||
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) —
|
||||
# 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:
|
||||
fee_coin = float(th["fee"])
|
||||
@@ -191,8 +206,12 @@ class ExecutionClient:
|
||||
fee_usd = fee_coin if spec.get("linear") else (
|
||||
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);
|
||||
# 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,
|
||||
fee_coin, fee_usd, order_id, state, verified, raw=resp,
|
||||
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,
|
||||
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche
|
||||
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"
|
||||
return self._submit(instrument, opp, amount, 0.0,
|
||||
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:
|
||||
"""Chiude a mercato la posizione e riverifica che il conto sia flat,
|
||||
leggendo la fee di chiusura dal trade history."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -54,6 +55,7 @@ class StrategyWorker:
|
||||
self.real_entry_fee_usd = 0.0
|
||||
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
|
||||
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_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_notional = state.get("real_entry_notional", 0.0)
|
||||
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_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_notional": self.real_entry_notional,
|
||||
"real_order_id": self.real_order_id,
|
||||
"real_tp_order_id": self.real_tp_order_id,
|
||||
"real_trades": self.real_trades,
|
||||
"real_first_notified": self.real_first_notified,
|
||||
"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
|
||||
self._notify("REAL_EXEC_LIVE", data)
|
||||
self.real_first_notified = True
|
||||
self._place_real_tp()
|
||||
else:
|
||||
self._log("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):
|
||||
"""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:
|
||||
return
|
||||
from src.live.execution import contract_spec
|
||||
step = contract_spec(self.exec_instrument)["step"]
|
||||
|
||||
# 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,
|
||||
self.real_amount, label=self.worker_id)
|
||||
exit_price = fill.fill_price or sim_exit
|
||||
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
|
||||
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
|
||||
if self.real_entry_price else 0.0
|
||||
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
|
||||
self.real_capital += real_pnl
|
||||
self.real_trades += 1
|
||||
@@ -258,16 +327,19 @@ class StrategyWorker:
|
||||
if exit_price and sim_exit else None)
|
||||
self._log("REAL_CLOSE", {
|
||||
"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),
|
||||
"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,
|
||||
"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),
|
||||
"sim_pnl_usd": round(sim_pnl, 4),
|
||||
"real_capital": round(self.real_capital, 4),
|
||||
"verified": fill.verified,
|
||||
"verified": verified,
|
||||
})
|
||||
|
||||
self.real_in_position = False
|
||||
@@ -277,6 +349,7 @@ class StrategyWorker:
|
||||
self.real_entry_fee_usd = 0.0
|
||||
self.real_entry_notional = 0.0
|
||||
self.real_order_id = ""
|
||||
self.real_tp_order_id = ""
|
||||
|
||||
def _close_position(self, current_price: float, reason: str):
|
||||
if not self.in_position:
|
||||
|
||||
Reference in New Issue
Block a user