diff --git a/scripts/live/microtest.py b/scripts/live/microtest.py index 6523009..7ccd15d 100644 --- a/scripts/live/microtest.py +++ b/scripts/live/microtest.py @@ -2,11 +2,11 @@ Conto reale = USDC -> strumento ESEGUIBILE = perp LINEARE `BTC_USDC-PERPETUAL` (amount in BTC, step 0.0001 ~ $6). Valida il percorso ordine->fill->reconciliation->chiusura con soldi VERI a size MINIMA -(~0x leva, decoupled dal segnale): test della plumbing, non della strategia. +(~0x leva, decoupled dal segnale): test della plumbing, non della strategia. Usa open()/close() +verificati di src/live/execution.py (logica entrata/uscita presa da Old). -Sicurezze: default DRY-RUN (NON invia). Guardrail hard in src/live/execution.py (solo -BTC_USDC-PERPETUAL, amount <= 0.0002 BTC). Pre-flight: ABORT se esiste gia' una posizione. Apertura -verificata; chiusura reduce_only; a fine test verifica il ritorno a FLAT (alert se no). +Sicurezze: default DRY-RUN. Pre-flight ABORT se posizione preesistente. La chiusura (reduce_only, +sempre permessa) flatta comunque dopo l'apertura; verifica finale di FLAT (alert se no). uv run python scripts/live/microtest.py # DRY-RUN: nessun ordine inviato uv run python scripts/live/microtest.py --live # invia il round-trip REALE @@ -19,7 +19,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) -from src.live.execution import MAX_AMOUNT, DeribitTrader, summarize_fill +from src.live.execution import FLAT_USD, MAX_AMOUNT, DeribitTrader INSTRUMENT = "BTC_USDC-PERPETUAL" AMOUNT = 0.0001 # base-coin (BTC) = 1 contratto minimo (~$6 a $63k) @@ -32,12 +32,10 @@ def main(): print("=" * 82) print(" MICRO-TEST esecuzione TP01 — round-trip 0.0001 BTC su BTC_USDC-PERPETUAL (leva ~0x)") print("=" * 82) - - # ---- pre-flight (sola lettura) ---- try: equity = float(t.account_summary("USDC").get("equity") or 0) mark = t.mark_price(INSTRUMENT) - pos0 = t.position_usd(INSTRUMENT) # per i lineari = size in BTC + pos0 = t.position_usd(INSTRUMENT) except Exception as e: print(f" PRE-FLIGHT FALLITO (read): {type(e).__name__}: {e}\n -> non procedo.") return @@ -45,51 +43,48 @@ def main(): notional = AMOUNT * mark print(f" conto USDC equity : ${equity:,.2f}") print(f" mark {INSTRUMENT} : ${mark:,.1f}") - print(f" posizione attuale : ${pos0:,.2f} notional (dev'essere 0)") # size lineare = USD notional - print(f" ordine 1 (apertura): BUY {AMOUNT:.4f} BTC market (~${notional:.2f}, leva {notional/equity:.4f}x)") - print(f" ordine 2 (chiusura): SELL {AMOUNT:.4f} BTC market reduce_only") - print(f" guardrail : solo {INSTRUMENT}, cap {MAX_AMOUNT[INSTRUMENT]} BTC") + print(f" posizione attuale : ${pos0:,.2f} notional (dev'essere 0)") + print(f" apertura : BUY {AMOUNT:.4f} BTC market (~${notional:.2f}, leva {notional/equity:.4f}x)") + print(f" chiusura : SELL {AMOUNT:.4f} BTC market reduce_only") + print(f" guardrail: solo {INSTRUMENT}, cap apertura {MAX_AMOUNT[INSTRUMENT]} BTC") - if abs(pos0) > 1e-9: - print(f"\n ABORT: posizione preesistente ({pos0:.5f} BTC). Non la tocco. Chiudila a mano e ripeti.") + if abs(pos0) >= FLAT_USD: + print(f"\n ABORT: posizione preesistente (${pos0:,.2f}). Non la tocco. Chiudila a mano e ripeti.") return - if not live: print("\n DRY-RUN: nessun ordine inviato. Rilancia con --live per il round-trip reale.") return # ---- LIVE: apertura ---- - print("\n >>> LIVE: invio APERTURA ...") - open_resp = t.place_market(INSTRUMENT, "buy", AMOUNT, label="tp01-microtest-open") - if isinstance(open_resp, dict) and open_resp.get("error"): - print(f" RIFIUTATO: {open_resp.get('error')} -> nessuna posizione. Stop.") + print("\n >>> LIVE: APERTURA ...") + fo = t.open(INSTRUMENT, "buy", AMOUNT, label="tp01-microtest-open") + if not fo.verified: + print(f" apertura NON verificata: {fo.notes}") + # safety: assicura comunque il flat + fc = t.close(INSTRUMENT, label="tp01-microtest-safeclose") + print(f" safe-close: {'eseguita' if fc else 'gia flat'}; posizione ${t.position_usd(INSTRUMENT):,.2f}") return - of = summarize_fill(open_resp) - ok_open, size_after = t.wait_until(INSTRUMENT, want_usd=notional, tol=max(1.0, notional * 0.5)) - if of: - print(f" FILL apertura: {of['amount']:.4f} BTC @ ${of['price']:,.1f} fee {of['fee']:.6f} USDC") - print(f" posizione dopo apertura: ${size_after:,.2f} notional ({'OK' if ok_open else f'ATTESO ~${notional:.2f} — VERIFICA'})") + print(f" FILL: {fo.filled:.4f} BTC @ ${fo.price:,.1f} fee {fo.fee_usdc:.6f} USDC (state={fo.state})") # ---- LIVE: chiusura (reduce_only) ---- - print(" >>> LIVE: invio CHIUSURA (reduce_only) ...") - close_resp = t.place_market(INSTRUMENT, "sell", AMOUNT, reduce_only=True, label="tp01-microtest-close") - cf = summarize_fill(close_resp) - flat_ok, size_end = t.wait_until(INSTRUMENT, want_usd=0.0, tol=1e-9) - if cf: - print(f" FILL chiusura: {cf['amount']:.4f} BTC @ ${cf['price']:,.1f} fee {cf['fee']:.6f} USDC") - print(f" posizione finale: ${size_end:,.2f} notional") + print(" >>> LIVE: CHIUSURA (reduce_only) ...") + fc = t.close(INSTRUMENT, label="tp01-microtest-close") + pos_end = t.position_usd(INSTRUMENT) + if fc: + print(f" FILL: {fc.filled:.4f} BTC @ ${fc.price:,.1f} fee {fc.fee_usdc:.6f} USDC (state={fc.state})") + print(f" posizione finale: ${pos_end:,.2f} notional") # ---- report ---- print("\n " + "-" * 62) - if flat_ok: + if abs(pos_end) < FLAT_USD: print(" ✓ ROUND-TRIP COMPLETO — posizione tornata a FLAT.") else: - print(f" ⚠️ ATTENZIONE: posizione NON flat (${size_end:,.2f}) — INTERVENTO MANUALE: chiudi a mano.") - if of and cf: - tot_fee = of["fee"] + cf["fee"] - pnl = AMOUNT * (cf["price"] - of["price"]) # lineare USDC: pnl in USDC - print(f" entry ${of['price']:,.1f} -> exit ${cf['price']:,.1f} | fee totale {tot_fee:.6f} USDC | " - f"pnl lordo {pnl:+.4f} USDC | netto {pnl - tot_fee:+.4f} USDC") + print(f" ⚠️ posizione NON flat (${pos_end:,.2f}) — INTERVENTO MANUALE: chiudi a mano.") + if fo.verified and fc: + tot_fee = fo.fee_usdc + fc.fee_usdc + pnl = AMOUNT * ((fc.price or 0) - (fo.price or 0)) + print(f" entry ${fo.price:,.1f} -> exit ${fc.price:,.1f} | fee {tot_fee:.6f} USDC | " + f"pnl lordo {pnl:+.4f} | netto {pnl - tot_fee:+.4f} USDC") print(" Validato: invio ordine reale, fill, fee reali, reconciliation, ritorno a flat.") diff --git a/src/live/dashboard.py b/src/live/dashboard.py index 944c2ae..04eefec 100644 --- a/src/live/dashboard.py +++ b/src/live/dashboard.py @@ -112,6 +112,15 @@ def html(): f"${t['price']:,.0f}") if not trows: trows = "nessun trade ancora (TP01 flat / in cash)" + live_trows = "" + for x in ((sh.get("live_trades") if sh and "error" not in sh else None) or []): + dcls = "g" if x["direction"] == "BUY" else "r" + when = str(pd.Timestamp(x["ts"], unit="ms", tz="UTC"))[:16] if x["ts"] else "—" + sym = x["instrument"].replace("_USDC-PERPETUAL", "").replace("-PERPETUAL", "") + live_trows += (f"{when}{sym}{x['direction']}" + f"{x['amount']:.4f}${x['price']:,.1f}{x['fee']:.5f}") + if not live_trows: + live_trows = "nessun trade reale eseguito (o conto non leggibile dal container)" return f""" PythagorasGoal — Portafoglio +.warn{{color:#f1c40f;font-size:12px}} +.section{{font-size:13px;font-weight:600;letter-spacing:.05em;margin:30px 0 12px;padding-bottom:7px;border-bottom:1px solid #222b36;color:#8a93a0}} +.section.live{{color:#e74c3c;border-color:#3a2329}}

PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01)

-
monitor PAPER + SHADOW · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE disabilitata
+
monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test)
+
PAPER — simulato (backtest + forward virtuale)
FULL Sharpe
{f['sharpe']:.2f}
HOLD-OUT Sharpe (2025-26)
{ho['sharpe']:.2f}
@@ -134,7 +146,6 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
EQUITY backtest (2019→oggi, €2k)
{svg_spark(d['spark'])}
Paper forward-only: {paper_html}
-
Shadow live — TP01 su Deribit (sola lettura, nessun ordine inviato):
{shadow_html}

Sleeve

{rows}
sleevepesoFULL ShDDHOLD Sh

Posizioni correnti (ultima barra chiusa)

@@ -142,6 +153,10 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b

Trades TP01 — entry/exit (segnale causale, ultimi 15)

{trows}
dataassetazioneposizioneprezzo
{yrs}
+
LIVE — Deribit mainnet (conto reale, sola lettura)
+
Shadow TP01 (cosa farebbe ORA sul conto reale, nessun ordine inviato):
{shadow_html}
+

Trades REALI eseguiti su Deribit

+{live_trows}
data/ora UTCstrum.diramountprezzofee USDC

⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.

""" diff --git a/src/live/deribit.py b/src/live/deribit.py index 2c32cd2..5aaab4b 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -30,7 +30,8 @@ _CONTRACT = { "BTC_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC", "linear": True}, "ETH_USDC-PERPETUAL": {"min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC", "linear": True}, } -INSTRUMENT = {"BTC": "BTC-PERPETUAL", "ETH": "ETH-PERPETUAL"} +# Il conto reale e' USDC -> mappiamo gli asset sui perp LINEARI USDC (gli unici eseguibili qui). +INSTRUMENT = {"BTC": "BTC_USDC-PERPETUAL", "ETH": "ETH_USDC-PERPETUAL"} # ----------------------------- costruzione ordini (pura, testabile, NIENTE rete) ----------------------------- @@ -41,16 +42,32 @@ def _quantize_step(value: float, step: float, mn: float) -> float: return float(max(n * Decimal(str(step)), Decimal(str(mn)))) -def notional_to_amount(instrument: str, notional_usd: float) -> float: - """USD notional -> `amount` Deribit (inverse: amount in USD), arrotondato allo step e clampato - al minimo. Ritorna 0.0 se |notional| < mezzo step (sotto-soglia: niente ordine).""" +def notional_to_amount(instrument: str, notional_usd: float, price: float | None = None) -> float: + """USD notional -> `amount` Deribit, arrotondato allo step e clampato al minimo. Ritorna 0.0 se + sotto mezzo step. INVERSE: amount in USD (price ignorato). LINEAR USDC: amount in base-coin + (units = notional/price -> serve il `price`; senza price ritorna 0.0).""" spec = _CONTRACT[instrument] step, mn = spec["step"], spec["min"] + if spec.get("linear"): + if not price: + return 0.0 + units = abs(notional_usd) / price + if units < step / 2: + return 0.0 + return _quantize_step(units, step, mn) if abs(notional_usd) < step / 2: return 0.0 return _quantize_step(abs(notional_usd), step, mn) +def quantize_price(instrument: str, price: float) -> float: + """Arrotonda il prezzo al tick dello strumento (per gli ordini stop/limit).""" + tick = _CONTRACT[instrument].get("tick") + if not tick or price <= 0: + return price + return float(round(price / tick) * Decimal(str(tick))) + + 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).""" @@ -58,12 +75,13 @@ def target_notional_usd(target_fraction: float, weight: float, equity_usd: float def build_rebalance_order(instrument: str, target_fraction: float, weight: float, - equity_usd: float, current_pos_usd: float) -> dict | None: + equity_usd: float, current_pos_usd: float, price: float | None = None) -> dict | None: """COSTRUISCE (non invia) l'ordine di ribilancio verso il target. Ritorna un dict-ordine o None - se sotto-soglia. Long-only TP01 -> target_notional >= 0; delta = target - posizione corrente.""" + se sotto-soglia. Long-only TP01 -> target_notional >= 0; delta = target - posizione corrente. + `price` (mark) serve a convertire il notional in base-coin per gli strumenti LINEARI USDC.""" tgt = target_notional_usd(target_fraction, weight, equity_usd) delta = tgt - current_pos_usd - amount = notional_to_amount(instrument, delta) + amount = notional_to_amount(instrument, delta, price=price) if amount == 0.0: return None is_exit = abs(tgt) < 1e-9 and abs(current_pos_usd) > 0 @@ -141,3 +159,9 @@ class DeribitRead: if p.get("instrument_name") == instrument or p.get("instrument") == instrument: return float(p.get("size") or p.get("size_currency") or 0.0) return 0.0 + + def trade_history(self, instrument: str, limit: int = 20) -> list[dict]: + """Trade REALMENTE eseguiti sul conto per `instrument` (fonte autorevole fee/fill).""" + 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 []) diff --git a/src/live/execution.py b/src/live/execution.py index 05c823a..db2bf14 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -1,73 +1,126 @@ -"""Esecuzione REALE su Deribit mainnet (via Cerbero MCP) — SOLO per il micro-test controllato. +"""Esecuzione REALE su Deribit mainnet (via Cerbero MCP) — entrata/uscita verificate. -Estende DeribitRead (sola lettura) coi metodi di trading MINIMI: market order, trade history (fee -reali), verifica posizione. GUARDRAIL HARD: solo BTC-PERPETUAL, notional <= MICRO_MAX_USD ($10), e -ogni open/close si verifica rileggendo la posizione. Nessun parametro di leva (su Deribit non e' -settabile per-ordine: l'esposizione la decide la SIZE dell'ordine — verificato nello stack pre-reset). +Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA dello stack pre-reset +(Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee +reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only. -⚠️ INVIA ORDINI REALI CON SOLDI VERI. Esiste solo per `scripts/live/microtest.py`. Non importare -altrove finche' il percorso live non e' validato + abilitato esplicitamente. +GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si +tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). +Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE). + +⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py). +Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente. """ from __future__ import annotations -import time +from dataclasses import dataclass -from src.live.deribit import DeribitRead, notional_to_amount +from src.live.deribit import DeribitRead, notional_to_amount, quantize_price -# Conto USDC -> perp LINEARE USDC: amount in base-coin (BTC), step 0.0001 (~$6 a $63k). -ALLOWED = {"BTC_USDC-PERPETUAL"} # solo questo strumento nel micro-test -MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.0002} # cap hard ~$13: micro, leva ~0 +# Conto USDC -> perp LINEARE USDC (amount in base-coin). Cap micro-test: ~$13. +ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} +MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.0002, "ETH_USDC-PERPETUAL": 0.005} +FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat class GuardrailError(RuntimeError): pass -def summarize_fill(resp: dict) -> dict | None: - """Riassume i trade di una risposta place_order: amount, prezzo medio ponderato, fee totale.""" - trades = resp.get("trades", []) if isinstance(resp, dict) else [] - if not trades: - return None - amt = sum(float(t.get("amount", 0) or 0) for t in trades) - px = (sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0) for t in trades) / amt) if amt else None - fee = sum(float(t.get("fee", 0) or 0) for t in trades) - return dict(amount=amt, price=px, fee=fee, n=len(trades)) +@dataclass +class Fill: + """Esito verificato di un ordine reale.""" + instrument: str + side: str + amount: float # richiesto (base-coin) + filled: float # realmente fillato (order.filled_amount, autorevole) + price: float | None # prezzo medio di fill + fee_usdc: float # fee reale (lineare USDC: gia' in USDC) + order_id: str | None + state: str | None + verified: bool + notes: str = "" + + +def _avg_price(order: dict, trades: list[dict]) -> float | None: + tr = [t for t in trades if t.get("price") and t.get("amount")] + if tr: + amt = sum(float(t["amount"]) for t in tr) + return (sum(float(t["price"]) * float(t["amount"]) for t in tr) / amt) if amt else None + return float(order.get("average_price") or 0) or None class DeribitTrader(DeribitRead): - """SOLA capacita' di trading consentita: market order entro i guardrail, + verifica. Niente altro.""" + """Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre.""" - def _check(self, instrument: str, amount: float) -> None: + def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool, + label: str, order_type: str = "market", price: float | None = None) -> Fill: if instrument not in ALLOWED: - raise GuardrailError(f"strumento non consentito nel micro-test: {instrument}") - cap = MAX_AMOUNT[instrument] - if amount <= 0 or amount > cap: - raise GuardrailError(f"size {amount} fuori dal cap micro-test (0, {cap}]") - - def place_market(self, instrument: str, side: str, amount: float, - reduce_only: bool = False, label: str = "tp01-microtest") -> dict: - """Market order REALE entro i guardrail. side in {'buy','sell'}. NESSUNA leva passata.""" - self._check(instrument, amount) + raise GuardrailError(f"strumento non consentito: {instrument}") if side not in ("buy", "sell"): raise GuardrailError(f"side non valido: {side}") + if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre + cap = MAX_AMOUNT.get(instrument, 0.0) + if amount <= 0 or amount > cap: + raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]") + if amount <= 0: + return Fill(instrument, side, amount, 0.0, None, 0.0, None, None, False, "amount<=0") + payload = {"instrument_name": instrument, "side": side, "amount": amount, - "type": "market", "label": label} + "type": order_type, "label": label} + if price is not None: + payload["price"] = price if reduce_only: payload["reduce_only"] = True - return self._unwrap(self._post("/mcp-deribit/tools/place_order", payload)) or {} + resp = self._unwrap(self._post("/mcp-deribit/tools/place_order", payload)) or {} - def trade_history(self, instrument: str, limit: int = 20) -> list[dict]: - 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 []) + if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error": + err = resp.get("error") if isinstance(resp, dict) else resp + return Fill(instrument, side, amount, 0.0, None, 0.0, None, "error", False, + notes=f"place_order error: {err}") - def wait_until(self, instrument: str, want_usd: float, tol: float = 1.0, - polls: int = 6, sleep: float = 0.6) -> tuple[bool, float]: - """Poll get_positions finche' la size si avvicina a want_usd (tol). Ritorna (ok, size_letta).""" - size = self.position_usd(instrument) - for _ in range(polls): - if abs(size - want_usd) <= tol: - return True, size - time.sleep(sleep) - size = self.position_usd(instrument) - return abs(size - want_usd) <= tol, size + order = resp.get("order", resp) or {} + trades = resp.get("trades", []) or [] + order_id = order.get("order_id") + state = order.get("order_state") + price_f = _avg_price(order, trades) + fee_usdc = sum(float(t.get("fee", 0) or 0) for t in trades) # lineare USDC: fee gia' in USDC + filled = float(order.get("filled_amount") or 0) or sum(float(t.get("amount", 0) or 0) for t in trades) + + if order_type == "market": + verified = (state == "filled") and bool(trades) + elif order_type == "stop_market": + verified = state in ("untriggered", "open", "filled") + else: + verified = state in ("open", "filled") + notes = "" if verified else f"non verificato (state={state}, trades={len(trades)})" + if verified and order_type == "market" and filled < amount - 1e-12: + notes = f"FILL PARZIALE: {filled} su {amount}" + return Fill(instrument, side, amount, filled, price_f, fee_usdc, order_id, state, verified, notes) + + # --- ENTRATA --- + def open(self, instrument: str, side: str, amount: float, label: str = "tp01-open") -> Fill: + """Apre a market (NON reduce_only), entro il cap. Verifica il fill reale.""" + return self._submit(instrument, side, amount, reduce_only=False, label=label) + + # --- USCITA (sempre permessa) --- + def close(self, instrument: str, label: str = "tp01-close") -> Fill | None: + """Chiude la posizione a market reduce_only. Legge la size reale (USD notional), la converte + in base-coin col mark, e flatta. None se gia' flat. Senza cap: si esce sempre.""" + pos_usd = self.position_usd(instrument) + if abs(pos_usd) < FLAT_USD: + return None + mark = self.mark_price(instrument) + amount = notional_to_amount(instrument, abs(pos_usd), price=mark) + side = "sell" if pos_usd > 0 else "buy" + return self._submit(instrument, side, amount, reduce_only=True, label=label) + + # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) --- + def place_disaster_sl(self, instrument: str, side_held: str, amount: float, + stop_price: float, label: str = "disaster-sl") -> Fill: + """STOP_MARKET reduce_only LONTANO (~-30%): in operativita' normale non scatta (l'exit della + strategia esce prima) -> 0 costo Sharpe; copre gli outage del runner. Trigger sul mark.""" + 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) diff --git a/src/live/shadow.py b/src/live/shadow.py index 53a7fde..a4bd867 100644 --- a/src/live/shadow.py +++ b/src/live/shadow.py @@ -128,7 +128,7 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - assets, orders = [], [] for a in ASSETS: inst = INSTRUMENT[a] - order = build_rebalance_order(inst, targets[a], WEIGHT, equity, positions[a]) + order = build_rebalance_order(inst, targets[a], WEIGHT, equity, positions[a], price=marks[a]) if order: orders.append(order) parity = None @@ -140,11 +140,26 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - position_usd=positions[a], mark=marks[a], mark_src=marks_src[a], order=order, paper=(float(paper_pos.get(a, 0.0)) if paper_pos else None), parity=parity, )) + live_trades = [] + if client is not None: + for a in ASSETS: + try: + for tr in client.trade_history(INSTRUMENT[a], limit=8): + live_trades.append(dict( + ts=int(tr.get("timestamp") or 0), instrument=INSTRUMENT[a], + direction=(tr.get("direction") or "").upper(), + amount=float(tr.get("amount") or 0), price=float(tr.get("price") or 0), + fee=float(tr.get("fee") or 0))) + except Exception: + pass + live_trades.sort(key=lambda r: r["ts"], reverse=True) + live_trades = live_trades[:12] + return dict( last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()), online=(client is not None and marks_src.get("BTC") == "mainnet"), real_equity=real_eq, equity=equity, eq_basis=eq_basis, - pos_src=pos_src, assets=assets, orders=orders, + pos_src=pos_src, assets=assets, orders=orders, live_trades=live_trades, flat=all(abs(targets[a]) < 1e-9 for a in ASSETS), paper_aligned=(paper_ts == last_ts), ) diff --git a/tests/test_live_shadow.py b/tests/test_live_shadow.py index a99041e..3a5a7d1 100644 --- a/tests/test_live_shadow.py +++ b/tests/test_live_shadow.py @@ -1,9 +1,9 @@ -"""Test deterministici dello SHADOW MODE di TP01 (src/live/deribit.py). +"""Test deterministici dello SHADOW/esecuzione TP01 (src/live/deribit.py). -Coprono la logica a rischio zero che NON tocca la rete: quantizzazione notional->contratti, sizing -target, costruzione ordine di ribilancio (buy/sell/exit/None), e PARITA' col backtest (il target -live = ultimo target della serie causale). Il fill reale (slippage/fee) NON e' qui: si valida solo -col micro-test mainnet. +Coprono la logica a rischio zero che NON tocca la rete: quantizzazione notional->contratti (INVERSE +e LINEARE USDC), sizing target, costruzione ordine di ribilancio (buy/sell/exit/None), e PARITA' col +backtest. Il conto reale e' USDC -> il path principale e' il LINEARE BTC_USDC-PERPETUAL. Il fill reale +(slippage/fee) NON e' qui: si valida solo col micro-test mainnet. """ import sys from pathlib import Path @@ -14,67 +14,70 @@ sys.path.insert(0, str(PROJECT_ROOT)) from src.live.deribit import (build_rebalance_order, notional_to_amount, target_notional_usd) from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d +LIN = "BTC_USDC-PERPETUAL" # lineare USDC: amount in BTC, step 0.0001 (path reale del conto) +PX = 64000.0 -def test_notional_to_amount_step_and_min(): - # BTC step/min $10 + +def test_notional_linear_usdc(): + # amount in base-coin = notional/price, quantizzato a 0.0001, clamp al minimo + assert notional_to_amount(LIN, 6.4, price=PX) == 0.0001 # 6.4/64000 = 0.0001 + assert notional_to_amount(LIN, 12.8, price=PX) == 0.0002 + assert notional_to_amount(LIN, 3.0, price=PX) == 0.0 # < mezzo step ($3.2) -> niente + assert notional_to_amount(LIN, 100, price=None) == 0.0 # lineare senza prezzo -> 0 + assert notional_to_amount(LIN, -6.4, price=PX) == 0.0001 # usa il valore assoluto + + +def test_notional_inverse_still_supported(): + # l'helper regge ancora gli inverse (amount in USD), senza prezzo assert notional_to_amount("BTC-PERPETUAL", 1000) == 1000 - assert notional_to_amount("BTC-PERPETUAL", 1006) == 1010 # round allo step - assert notional_to_amount("BTC-PERPETUAL", 7) == 10 # clamp al minimo (>= mezzo step) - assert notional_to_amount("BTC-PERPETUAL", 3) == 0.0 # < mezzo step -> niente ordine - # ETH step/min $1 - assert notional_to_amount("ETH-PERPETUAL", 33.7) == 34 - assert notional_to_amount("ETH-PERPETUAL", 0.4) == 0.0 - # usa il valore assoluto (il segno lo decide il delta a monte) - assert notional_to_amount("BTC-PERPETUAL", -1000) == 1000 + assert notional_to_amount("BTC-PERPETUAL", 7) == 10 # clamp al minimo + assert notional_to_amount("BTC-PERPETUAL", 3) == 0.0 def test_no_float_artifacts(): - # _quantize_step usa Decimal: nessun 0.07200000000000001 & co. - v = notional_to_amount("ETH-PERPETUAL", 72.0) - assert v == 72 and float(v).is_integer() + v = notional_to_amount(LIN, 0.0001 * PX * 72, price=PX) # 72 step esatti + assert v == 0.0072 and abs(v - 0.0072) < 1e-12 def test_target_notional_5050_weight(): - # 50/50 book: notional asset = 0.5 * frazione * equity assert target_notional_usd(1.0, 0.5, 2000) == 1000 assert target_notional_usd(2.0, 0.5, 2000) == 2000 # leva-cap 2x -> piena equity sull'asset assert target_notional_usd(0.0, 0.5, 2000) == 0.0 -def test_build_order_entry(): - o = build_rebalance_order("BTC-PERPETUAL", target_fraction=1.0, weight=0.5, - equity_usd=2000, current_pos_usd=0.0) - assert o["side"] == "buy" and o["amount"] == 1000 and o["reduce_only"] is False +def test_build_order_entry_linear(): + o = build_rebalance_order(LIN, target_fraction=1.0, weight=0.5, + equity_usd=2000, current_pos_usd=0.0, price=PX) + assert o["side"] == "buy" and o["reduce_only"] is False assert o["target_notional"] == 1000 and o["delta_notional"] == 1000 + assert abs(o["amount"] - 0.0156) < 1e-9 # 1000/64000=0.015625 -> 0.0156 def test_build_order_exit_is_reduce_only(): - o = build_rebalance_order("ETH-PERPETUAL", target_fraction=0.0, weight=0.5, - equity_usd=2000, current_pos_usd=1000.0) - assert o["side"] == "sell" and o["reduce_only"] is True and o["amount"] == 1000 + o = build_rebalance_order(LIN, target_fraction=0.0, weight=0.5, + equity_usd=2000, current_pos_usd=1000.0, price=PX) + assert o["side"] == "sell" and o["reduce_only"] is True and o["amount"] > 0 def test_build_order_already_at_target_is_none(): - o = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, current_pos_usd=1000.0) + o = build_rebalance_order(LIN, 1.0, 0.5, 2000, current_pos_usd=1000.0, price=PX) assert o is None # delta 0 -> nessun ordine def test_build_order_subthreshold_is_none(): - # delta $3 su BTC (< mezzo step $5) -> niente ordine - o = build_rebalance_order("BTC-PERPETUAL", 0.5015, 0.5, 2000, current_pos_usd=498.5) + # delta $2 (< mezzo step in notional, $3.2 a 64k) -> niente ordine + o = build_rebalance_order(LIN, 1.0, 0.5, 2000, current_pos_usd=998.0, price=PX) assert o is None def test_partial_rebalance_direction(): - # target $1000, ho $600 -> compro $400; ho $1400 -> vendo $400 - up = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, 600.0) - dn = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, 1400.0) - assert up["side"] == "buy" and up["amount"] == 400 - assert dn["side"] == "sell" and dn["amount"] == 400 and dn["reduce_only"] is False + up = build_rebalance_order(LIN, 1.0, 0.5, 2000, 600.0, price=PX) # compra il delta + dn = build_rebalance_order(LIN, 1.0, 0.5, 2000, 1400.0, price=PX) # vende il delta + assert up["side"] == "buy" and up["delta_notional"] == 400 + assert dn["side"] == "sell" and dn["delta_notional"] == -400 and dn["reduce_only"] is False def test_parity_live_target_equals_backtest(): - # il target live (current_target) DEVE essere l'ultimo della serie causale del backtest from src.backtest.harness import load tp = TrendPortfolio(**CANONICAL) df = resample_1d(load("BTC", "1h"))