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
| sleeve | peso | FULL Sh | DD | HOLD Sh |
{rows}
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)
| data | asset | azione | posizione | prezzo |
{trows}
{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
+| data/ora UTC | strum. | dir | amount | prezzo | fee USDC |
{live_trows}
⚠️ 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"))