feat(live): reconcile resting + orphan single-leg + circuit-breaker venue-lock + FEED_BOOK_GAP

Codice della tornata v1.1.27/28 (gia' in produzione, mai committato):
- reconcile_account: estensione ordini RESTING (FILLED_UNBOOKED/MISSING/STALE,
  caso MR02_BTC: TP fillato di notte scoperto ore dopo) + expected_resting in books
- strategy_worker: orphan_legs su REAL_CLOSE_PARTIAL anche single-leg, persistito
- execution: circuit-breaker su venue-lock admin (stop ordini dopo errori ripetuti)
- runner/hourly_report: alert FEED_BOOK_GAP + timestamp closed trades
- cerbero_client: get_open_orders (merge all + trigger_all)
Test: 12 nuovi, suite completa 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-12 20:29:02 +00:00
parent a2d581691a
commit 612f2bfced
11 changed files with 628 additions and 25 deletions
+79
View File
@@ -0,0 +1,79 @@
"""Orfani single-leg (2026-06-12): un close fallito/cappato registra la quota
residua in orphan_legs (parita' coi pairs) cosi' il reconciler la conta come
drift SPIEGATO — prima il REAL_CLOSE_PARTIAL di MR07 (0.102 ETH nel lock testnet)
lasciava drift non spiegato. Nessuna rete: executor finto."""
import json
from types import SimpleNamespace
from src.live.execution import Fill
from src.live.strategy_worker import StrategyWorker
from src.strategies.base import Signal
from src.live import books
class FailingCloseExec:
"""Apre ok, chiude con fill ZERO (venue locked / netting negato)."""
verify_polls = 1
verify_sleep = 0.0
disaster_sl_pct = None
def open(self, instrument, side, notional, label=None):
amt = round(notional / 100.0, 6)
return Fill(instrument, side, notional, amt, 100.0, 0.0, 0.05,
"oid-open", "filled", True, filled_amount=amt)
def place_tp_limit(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def cancel_order(self, oid):
return {"state": "cancelled"}
def resting_fills(self, instrument, oid):
return 0.0, None, 0.0
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell", 0.0, amount, None, 0.0, 0.0,
None, "error", False, notes="place_order error: locked_by_admin",
filled_amount=0.0)
def _worker(tmp_path):
return StrategyWorker(
strategy=SimpleNamespace(name="MR07_return_reversal", fee_rt=0.001),
asset="ETH", tf="1h", capital=100.0, position_size=0.5, leverage=2.0,
data_dir=tmp_path, executor=FailingCloseExec(),
exec_instrument="ETH_USDC-PERPETUAL")
def test_failed_close_registers_orphan(tmp_path, monkeypatch):
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
w = _worker(tmp_path)
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
metadata={"max_bars": 12}), 100.0)
amt = w.real_amount
assert w.real_in_position and amt > 0
w._close_position(105.0, "time_limit")
assert not w.real_in_position
assert len(w.orphan_legs) == 1
o = w.orphan_legs[0]
assert o["instrument"] == "ETH_USDC-PERPETUAL"
assert o["entry_side"] == "buy" and abs(o["amount"] - amt) < 1e-9
# persistito nello status (resume-safe) e visto da books.real_books
st = json.loads((tmp_path / w.worker_id / "status.json").read_text())
assert st["orphan_legs"] == w.orphan_legs
monkeypatch.setattr(books, "PAPER", tmp_path)
_, orphans = books.real_books()
assert abs(orphans["ETH_USDC-PERPETUAL"] - amt) < 1e-9 # buy = firmato +
def test_clean_close_no_orphan(tmp_path, monkeypatch):
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
w = _worker(tmp_path)
w.executor.close_amount = lambda instrument, side, amount, label=None: Fill(
instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
"filled", True, filled_amount=amount)
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
metadata={"max_bars": 12}), 100.0)
w._close_position(105.0, "time_limit")
assert w.orphan_legs == []