feat(live): disaster-SL on-book con lifecycle completo (idempotente) nel loop di esecuzione
ensure_disaster_sl(): garantisce UN solo STOP_MARKET reduce_only a ~-30% coerente con la posizione, ad ogni run del loop, per asset: - flat -> cancella i bracket orfani; - long -> assicura lo stop (size = posizione, prezzo al tick); - gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn ne' gap di protezione fra cancel e place). - deribit.py: open_orders (merge type all+trigger_all), disaster_stop_price. - execution.py: cancel_order + ensure_disaster_sl. - live_execute.py: gestione bracket ogni run, gated come l'esecuzione. Validato armato: flat -> disaster-SL 'flat' (cleanup), zero ordini. Test 28/28. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ def load_config() -> dict:
|
|||||||
cfg.setdefault("execution_enabled", False)
|
cfg.setdefault("execution_enabled", False)
|
||||||
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
||||||
cfg.setdefault("min_order_usd", 5.0)
|
cfg.setdefault("min_order_usd", 5.0)
|
||||||
|
cfg.setdefault("disaster_sl_pct", 0.30)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ def main():
|
|||||||
do_execute = want_execute and enabled
|
do_execute = want_execute and enabled
|
||||||
max_notional = float(cfg["max_notional_per_asset_usd"])
|
max_notional = float(cfg["max_notional_per_asset_usd"])
|
||||||
min_order = float(cfg["min_order_usd"])
|
min_order = float(cfg["min_order_usd"])
|
||||||
|
sl_pct = float(cfg["disaster_sl_pct"])
|
||||||
|
|
||||||
r = shadow_report() # targets causali + conto/posizioni reali (online)
|
r = shadow_report() # targets causali + conto/posizioni reali (online)
|
||||||
equity = r["equity"]
|
equity = r["equity"]
|
||||||
@@ -65,7 +67,7 @@ def main():
|
|||||||
print(f" modo : {mode}")
|
print(f" modo : {mode}")
|
||||||
print(f" gate : execution_enabled={enabled} | --execute={want_execute}")
|
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" 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")
|
print(f" ultima barra : {r['last_data']}\n")
|
||||||
|
|
||||||
if not r["online"]:
|
if not r["online"]:
|
||||||
@@ -88,7 +90,8 @@ def main():
|
|||||||
act = f"REDUCE ${-delta:,.0f}"
|
act = f"REDUCE ${-delta:,.0f}"
|
||||||
print(f" {asset:<3} target {frac:+.3f}x -> ${tgt:,.0f} | pos ${cur:,.0f} | delta ${delta:+,.0f} -> {act}")
|
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"):
|
if do_execute:
|
||||||
|
if not act.startswith("HOLD"):
|
||||||
fills = trader.rebalance_to(INSTRUMENT[asset], tgt, mark, min_usd=min_order)
|
fills = trader.rebalance_to(INSTRUMENT[asset], tgt, mark, min_usd=min_order)
|
||||||
newpos = trader.position_usd(INSTRUMENT[asset])
|
newpos = trader.position_usd(INSTRUMENT[asset])
|
||||||
for f in fills:
|
for f in fills:
|
||||||
@@ -98,6 +101,8 @@ def main():
|
|||||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||||
verified=f.verified, notes=f.notes, pos_after=newpos))
|
verified=f.verified, notes=f.notes, pos_after=newpos))
|
||||||
print(f" reconcile: pos ${newpos:,.0f}")
|
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)
|
actions.append(act)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ def quantize_price(instrument: str, price: float) -> float:
|
|||||||
return float(round(price / tick) * Decimal(str(tick)))
|
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:
|
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.
|
"""Notional bersaglio (USD) di un asset = peso nel book * frazione-di-equity TP01 * equity.
|
||||||
Coerente col paper trader (esposizione asset = WEIGHT * target * 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",
|
out = self._unwrap(self._post("/mcp-deribit/tools/get_trade_history",
|
||||||
{"limit": limit, "instrument_name": instrument}))
|
{"limit": limit, "instrument_name": instrument}))
|
||||||
return out if isinstance(out, list) else (out.get("trades", []) if isinstance(out, dict) else [])
|
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())
|
||||||
|
|||||||
+36
-2
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
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
|
# 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
|
# (~$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"
|
opp = "sell" if side_held == "buy" else "buy"
|
||||||
return self._submit(instrument, opp, amount, reduce_only=True, label=label,
|
return self._submit(instrument, opp, amount, reduce_only=True, label=label,
|
||||||
order_type="stop_market", price=quantize_price(instrument, stop_price))
|
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)
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ def test_partial_rebalance_direction():
|
|||||||
assert dn["side"] == "sell" and dn["delta_notional"] == -400 and dn["reduce_only"] is False
|
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():
|
def test_parity_live_target_equals_backtest():
|
||||||
from src.backtest.harness import load
|
from src.backtest.harness import load
|
||||||
tp = TrendPortfolio(**CANONICAL)
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
|||||||
Reference in New Issue
Block a user