diff --git a/scripts/live/live_execute.py b/scripts/live/live_execute.py index 9bf7944..0cf9e25 100644 --- a/scripts/live/live_execute.py +++ b/scripts/live/live_execute.py @@ -37,6 +37,7 @@ def load_config() -> dict: cfg.setdefault("execution_enabled", False) cfg.setdefault("max_notional_per_asset_usd", 300.0) cfg.setdefault("min_order_usd", 5.0) + cfg.setdefault("disaster_sl_pct", 0.30) return cfg @@ -53,6 +54,7 @@ def main(): do_execute = want_execute and enabled max_notional = float(cfg["max_notional_per_asset_usd"]) min_order = float(cfg["min_order_usd"]) + sl_pct = float(cfg["disaster_sl_pct"]) r = shadow_report() # targets causali + conto/posizioni reali (online) equity = r["equity"] @@ -65,7 +67,7 @@ def main(): print(f" modo : {mode}") print(f" gate : execution_enabled={enabled} | --execute={want_execute}") print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}") - print(f" sizing base : ${equity:,.2f} | cap/asset ${max_notional:.0f} | min ordine ${min_order:.0f}") + print(f" sizing base : ${equity:,.2f} | cap/asset ${max_notional:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%") print(f" ultima barra : {r['last_data']}\n") if not r["online"]: @@ -88,16 +90,19 @@ def main(): act = f"REDUCE ${-delta:,.0f}" print(f" {asset:<3} target {frac:+.3f}x -> ${tgt:,.0f} | pos ${cur:,.0f} | delta ${delta:+,.0f} -> {act}") - if do_execute and not act.startswith("HOLD"): - fills = trader.rebalance_to(INSTRUMENT[asset], tgt, mark, min_usd=min_order) - newpos = trader.position_usd(INSTRUMENT[asset]) - for f in fills: - print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price:,.1f} fee {f.fee_usdc:.5f} " - f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})") - log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act, - side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc, - verified=f.verified, notes=f.notes, pos_after=newpos)) - print(f" reconcile: pos ${newpos:,.0f}") + if do_execute: + if not act.startswith("HOLD"): + fills = trader.rebalance_to(INSTRUMENT[asset], tgt, mark, min_usd=min_order) + newpos = trader.position_usd(INSTRUMENT[asset]) + for f in fills: + print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price:,.1f} fee {f.fee_usdc:.5f} " + f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})") + log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act, + side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc, + verified=f.verified, notes=f.notes, pos_after=newpos)) + print(f" reconcile: pos ${newpos:,.0f}") + ds = trader.ensure_disaster_sl(INSTRUMENT[asset], sl_pct) # bracket: piazza se long, pulisce se flat + print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else "")) actions.append(act) print() diff --git a/src/live/deribit.py b/src/live/deribit.py index 5aaab4b..088edb3 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -68,6 +68,12 @@ def quantize_price(instrument: str, price: float) -> float: return float(round(price / tick) * Decimal(str(tick))) +def disaster_stop_price(instrument: str, mark: float, sl_pct: float, long: bool = True) -> float: + """Prezzo del disaster-stop: ~sl_pct SOTTO il mark per un long (SOPRA per uno short), al tick.""" + raw = mark * (1 - sl_pct) if long else mark * (1 + sl_pct) + return quantize_price(instrument, raw) + + def target_notional_usd(target_fraction: float, weight: float, equity_usd: float) -> float: """Notional bersaglio (USD) di un asset = peso nel book * frazione-di-equity TP01 * equity. Coerente col paper trader (esposizione asset = WEIGHT * target * equity).""" @@ -165,3 +171,20 @@ class DeribitRead: out = self._unwrap(self._post("/mcp-deribit/tools/get_trade_history", {"limit": limit, "instrument_name": instrument})) return out if isinstance(out, list) else (out.get("trades", []) if isinstance(out, dict) else []) + + def open_orders(self, instrument: str) -> list[dict]: + """Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger + untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.""" + cur = _CONTRACT[instrument]["settle"] + seen: dict = {} + for typ in ("all", "trigger_all"): + try: + out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders", + {"currency": cur, "type": typ})) + lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) + for o in lst: + if o.get("instrument_name") == instrument or o.get("instrument") == instrument: + seen[o.get("order_id")] = o + except Exception: + pass + return list(seen.values()) diff --git a/src/live/execution.py b/src/live/execution.py index 02eac0d..9df643d 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import dataclass -from src.live.deribit import DeribitRead, notional_to_amount, quantize_price +from src.live.deribit import DeribitRead, disaster_stop_price, notional_to_amount, quantize_price # Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger # (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello @@ -148,4 +148,38 @@ class DeribitTrader(DeribitRead): opp = "sell" if side_held == "buy" else "buy" return self._submit(instrument, opp, amount, reduce_only=True, label=label, order_type="stop_market", price=quantize_price(instrument, stop_price)) - # trade_history e' ereditato da DeribitRead (read-only) + + def cancel_order(self, order_id: str) -> dict: + return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} + + DISASTER_LABEL = "tp01-disaster" + + def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict: + """Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente): + - flat -> cancella eventuali bracket orfani; + - long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione; + - gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).""" + pos = self.position_usd(instrument) + brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == self.DISASTER_LABEL] + if abs(pos) < FLAT_USD: + for o in brackets: + self.cancel_order(o.get("order_id")) + return {"state": "flat", "cancelled": len(brackets)} + mark = self.mark_price(instrument) + long = pos > 0 + want_amount = notional_to_amount(instrument, abs(pos), price=mark) + want_stop = disaster_stop_price(instrument, mark, sl_pct, long=long) + if len(brackets) == 1: + o = brackets[0] + amt = float(o.get("amount") or 0) + stp = float(o.get("trigger_price") or o.get("stop_price") or o.get("price") or 0) + if want_amount and abs(amt - want_amount) < want_amount * 0.1 and stp > 0 \ + and abs(stp - want_stop) / want_stop < 0.05: + return {"state": "ok", "stop": stp, "amount": amt} + for o in brackets: # incoerente o multipli -> ricostruisci UN bracket + self.cancel_order(o.get("order_id")) + f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount, want_stop, + label=self.DISASTER_LABEL) + return {"state": "placed" if f.verified else "place-failed", "stop": want_stop, + "amount": want_amount, "notes": f.notes} + # trade_history / open_orders ereditati da DeribitRead (read-only) diff --git a/tests/test_live_shadow.py b/tests/test_live_shadow.py index 3a5a7d1..b399742 100644 --- a/tests/test_live_shadow.py +++ b/tests/test_live_shadow.py @@ -77,6 +77,12 @@ def test_partial_rebalance_direction(): assert dn["side"] == "sell" and dn["delta_notional"] == -400 and dn["reduce_only"] is False +def test_disaster_stop_price(): + from src.live.deribit import disaster_stop_price + assert disaster_stop_price(LIN, 64000, 0.30, long=True) == 44800.0 # -30%, al tick + assert disaster_stop_price(LIN, 64000, 0.30, long=False) == 83200.0 # +30% (short) + + def test_parity_live_target_equals_backtest(): from src.backtest.harness import load tp = TrendPortfolio(**CANONICAL)