a0842a61cc
Riusa la convenzione della dashboard (get_ticker_batch USDC perp, real_entry vs mark; pairs a 2 gambe). Mostra unrealized per trade + Σ REALE, e segnala che il ledger unrealized e' sul feed sim-decisione testnet DISLOCATO (lo scarto e' dislocazione, non soldi). Fix sys.path: import src.* anche eseguito come script file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
235 lines
9.8 KiB
Python
235 lines
9.8 KiB
Python
"""Report "stato trades" — separa SEMPRE pool reale e paper-stats, e mostra sia
|
|
realizzato che unrealized. Verita' aggregata = ledger PORT06; il dettaglio per-trade
|
|
viene dai worker dir.
|
|
|
|
Fonte unica della separazione (NON la memoria — qui ho gia' sbagliato una volta
|
|
includendo XS01 nell'equity):
|
|
data/portfolio_paper/ -> POOL REALE (nel ledger, muove l'equity/il conto)
|
|
data/portfolio_paper_stats/ -> PAPER-STATS (solo statistica, MAI nel ledger)
|
|
|
|
Uso:
|
|
uv run python scripts/analysis/trades_status.py # ultime 24h
|
|
uv run python scripts/analysis/trades_status.py --since 2026-06-16T14:00
|
|
"""
|
|
import argparse
|
|
import datetime as dt
|
|
import glob
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # project root -> import src.*
|
|
|
|
DATA = Path("data")
|
|
POOL_DIR = DATA / "portfolio_paper"
|
|
STATS_DIR = DATA / "portfolio_paper_stats"
|
|
LEDGER = DATA / "portfolios" / "PORT06" / "status.json"
|
|
EQUITY = DATA / "portfolios" / "PORT06" / "equity.jsonl"
|
|
|
|
|
|
def _closes(worker_dir: Path, since: dt.datetime):
|
|
"""CLOSE events dei worker in worker_dir dal momento `since`."""
|
|
out = []
|
|
for f in sorted(glob.glob(str(worker_dir / "*" / "trades.jsonl"))):
|
|
name = Path(f).parent.name
|
|
for line in open(f):
|
|
try:
|
|
d = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if d.get("event") != "CLOSE":
|
|
continue
|
|
try:
|
|
t = dt.datetime.fromisoformat(d.get("ts", ""))
|
|
except ValueError:
|
|
continue
|
|
if t < since:
|
|
continue
|
|
# PnL che conta: reale se eseguito (real_truth), altrimenti il sim/pnl
|
|
real = d.get("real_pnl")
|
|
real = real if real is not None else d.get("pnl", 0.0)
|
|
out.append({
|
|
"ts": d.get("ts", "")[:19], "name": name, "reason": d.get("reason"),
|
|
"held": d.get("bars_held"), "pnl": d.get("pnl"),
|
|
"real": d.get("real_pnl"), "sim": d.get("sim_pnl"), "eff": real or 0.0,
|
|
})
|
|
return sorted(out, key=lambda r: r["ts"])
|
|
|
|
|
|
def _open_positions(worker_dir: Path):
|
|
"""Posizioni aperte con i campi per il PnL non realizzato (entry REALE di
|
|
esecuzione quando eseguito; il feed di decisione sim e' dislocato)."""
|
|
out = []
|
|
for f in sorted(glob.glob(str(worker_dir / "*" / "status.json"))):
|
|
d = json.load(open(f))
|
|
if not d.get("in_position"):
|
|
continue
|
|
wid = Path(f).parent.name
|
|
is_pair = "entry_a" in d
|
|
executed = bool(d.get("real_in_position"))
|
|
row = {"name": wid, "pair": is_pair, "real": executed,
|
|
"dir": d.get("direction", 0), "held": d.get("bars_held"),
|
|
"max_bars": d.get("max_bars"), "tp": d.get("tp", 0.0),
|
|
"unreal": None}
|
|
if is_pair:
|
|
a_, b_ = wid.split("__")[1].split("_")
|
|
row.update({
|
|
"assets": [a_, b_], "leg_a": a_, "leg_b": b_,
|
|
"entry_a": d.get("real_entry_a") if executed else d.get("entry_a"),
|
|
"entry_b": d.get("real_entry_b") if executed else d.get("entry_b"),
|
|
"notional_a": d.get("real_notional_a") or 0.0,
|
|
"notional_b": d.get("real_notional_b") or 0.0,
|
|
})
|
|
else:
|
|
asset = wid.split("__")[1]
|
|
entry = (d.get("real_entry_price") if executed else None) or d.get("entry_price") or 0.0
|
|
row.update({
|
|
"assets": [asset], "asset": asset, "entry": entry,
|
|
"notional": d.get("real_entry_notional") or round(d.get("capital", 0.0) * 0.5 * 3, 1),
|
|
})
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def _marks(assets: set) -> dict:
|
|
"""Mark correnti USDC perp (best-effort). {} se Cerbero non risponde."""
|
|
if not assets:
|
|
return {}
|
|
try:
|
|
from src.live.cerbero_client import CerberoClient
|
|
insts = [f"{a}_USDC-PERPETUAL" for a in assets]
|
|
data = CerberoClient().get_ticker_batch(insts)
|
|
return {t["instrument_name"].split("_")[0].replace("-PERPETUAL", ""): t.get("mark_price")
|
|
for t in data.get("tickers", [])}
|
|
except Exception as e:
|
|
print(f"[trades_status] mark fetch fallita ({e!r}) -> PnL aperti n/d", file=sys.stderr)
|
|
return {}
|
|
|
|
|
|
def _annotate_pnl(positions, marks):
|
|
"""PnL non realizzato live, convenzione identica alla dashboard (entry reale vs mark USDC)."""
|
|
for a in positions:
|
|
if a["pair"]:
|
|
ma, mb = marks.get(a["leg_a"]), marks.get(a["leg_b"])
|
|
if ma and mb and a.get("entry_a") and a.get("entry_b"):
|
|
s = 1 if a["dir"] > 0 else -1 # +1: long A / short B
|
|
ga = a["notional_a"] * s * (ma - a["entry_a"]) / a["entry_a"]
|
|
gb = a["notional_b"] * (-s) * (mb - a["entry_b"]) / a["entry_b"]
|
|
a["unreal"] = ga + gb
|
|
a["mark"], a["mark_b"] = ma, mb
|
|
else:
|
|
mk = marks.get(a["asset"])
|
|
if mk and a.get("entry"):
|
|
sign = 1 if a["dir"] > 0 else -1
|
|
pct = sign * (mk - a["entry"]) / a["entry"]
|
|
a["unreal"] = a["notional"] * pct
|
|
a["unreal_pct"] = pct * 100
|
|
a["mark"] = mk
|
|
return positions
|
|
|
|
|
|
def _equity_at(since: dt.datetime):
|
|
"""Equity piu' vicino (e <=) a `since`, per il Δ della finestra."""
|
|
base = None
|
|
if not EQUITY.exists():
|
|
return None
|
|
for line in open(EQUITY):
|
|
try:
|
|
d = json.loads(line)
|
|
t = dt.datetime.fromisoformat(d["ts"])
|
|
except (json.JSONDecodeError, KeyError, ValueError):
|
|
continue
|
|
if t <= since:
|
|
base = d["equity"]
|
|
else:
|
|
break
|
|
return base
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--since", default=None,
|
|
help="ISO (es. 2026-06-16T14:00). Default: 24h fa.")
|
|
args = ap.parse_args()
|
|
|
|
now = dt.datetime.now(dt.timezone.utc)
|
|
if args.since:
|
|
since = dt.datetime.fromisoformat(args.since)
|
|
if since.tzinfo is None:
|
|
since = since.replace(tzinfo=dt.timezone.utc)
|
|
else:
|
|
since = now - dt.timedelta(hours=24)
|
|
|
|
led = json.load(open(LEDGER))
|
|
equity = led["equity"]
|
|
cap = led["total_capital"]
|
|
unreal = equity - cap
|
|
eq_base = _equity_at(since)
|
|
ver = Path("VERSION").read_text().strip() if Path("VERSION").exists() else "?"
|
|
|
|
print(f"STATO TRADES — v{ver} — {now:%Y-%m-%d %H:%M} UTC (finestra dal {since:%Y-%m-%d %H:%M})\n")
|
|
|
|
# ---- aggregato (ledger = verita') ----
|
|
print("LEDGER PORT06 (verita' aggregata)")
|
|
print(f" equity = {equity:.2f}")
|
|
print(f" capitale realizz.= {cap:.2f}")
|
|
print(f" unrealized aperti= {unreal:+.2f} (equity - capitale; pos. aperte mark-to-market)")
|
|
print(f" peak {led.get('peak'):.2f} maxDD {led.get('max_dd')}%")
|
|
if eq_base is not None:
|
|
print(f" Δ equity finestra= {equity - eq_base:+.2f} ({eq_base:.2f} -> {equity:.2f})")
|
|
|
|
# ---- POOL REALE ----
|
|
pool = _closes(POOL_DIR, since)
|
|
s_real = sum(r["eff"] for r in pool)
|
|
wins = sum(1 for r in pool if r["eff"] > 0)
|
|
print(f"\n=== POOL REALE (muove l'equity) — {len(pool)} chiusure, {wins}W/{len(pool)-wins}L ===")
|
|
for r in pool:
|
|
tag = "LOSS" if r["eff"] < 0 else "win "
|
|
print(f" {r['ts']} {tag} {r['name']:36} {str(r['reason']):11} "
|
|
f"real={r['real']} sim={r['sim']}")
|
|
print(f" Σ REALIZZATO POOL = {s_real:+.2f}")
|
|
op = _open_positions(POOL_DIR)
|
|
if op:
|
|
assets = set().union(*[set(p["assets"]) for p in op])
|
|
marks = _marks(assets)
|
|
_annotate_pnl(op, marks)
|
|
s_unreal = sum(p["unreal"] for p in op if p["unreal"] is not None)
|
|
n_marked = sum(1 for p in op if p["unreal"] is not None)
|
|
print(f"\n posizioni aperte ({len(op)}) — PnL non realizzato live (entry reale vs mark USDC):")
|
|
for p in sorted(op, key=lambda x: (x["unreal"] is None, x.get("unreal") or 0)):
|
|
d = {1: "LONG", -1: "SHORT"}.get(p["dir"], "?")
|
|
if p["unreal"] is not None:
|
|
pct = f"{p['unreal_pct']:+.2f}%" if not p["pair"] and "unreal_pct" in p else ""
|
|
mark = f"mark={p['mark']}" if not p["pair"] else f"mk {p['mark']}/{p.get('mark_b')}"
|
|
pnl = f"{p['unreal']:+.2f}"
|
|
else:
|
|
pct = mark = ""; pnl = "n/d (no mark)"
|
|
ent = f"entry={p['entry']}" if not p["pair"] else f"a={p.get('entry_a')} b={p.get('entry_b')}"
|
|
print(f" {p['name']:36} {d:5} h={p['held']}/{p['max_bars']} {ent} {mark} "
|
|
f"PnL {pnl} {pct}")
|
|
print(f" Σ UNREALIZED REALE = {s_unreal:+.2f} ({n_marked}/{len(op)} con mark live) "
|
|
f"<- verita' del conto (entry reale vs mark USDC)")
|
|
print(f" ledger unrealized = {unreal:+.2f} (feed sim-decisione testnet DISLOCATO); "
|
|
f"lo scarto {s_unreal - unreal:+.2f} e' la dislocazione del feed, non soldi.")
|
|
|
|
# ---- PAPER-STATS ----
|
|
stats = _closes(STATS_DIR, since)
|
|
s_stats = sum(r["eff"] for r in stats)
|
|
print(f"\n=== PAPER-STATS (solo statistica — NON tocca equity/conto) — {len(stats)} chiusure ===")
|
|
for r in stats:
|
|
print(f" {r['ts']} {r['name']:36} pnl={r['pnl']}")
|
|
print(f" Σ PAPER-STATS = {s_stats:+.2f} <- ignorare per l'equity")
|
|
|
|
# ---- riconciliazione ----
|
|
print("\nRICONCILIAZIONE")
|
|
print(f" realizzato POOL {s_real:+.2f} + Δunrealized aperti ≈ Δ equity finestra")
|
|
if eq_base is not None:
|
|
residuo = (equity - eq_base) - s_real
|
|
print(f" Δequity {equity - eq_base:+.2f} - realizzato {s_real:+.2f} = "
|
|
f"{residuo:+.2f} (= Δunrealized + ribilanci/timing)")
|
|
print(f" paper-stats {s_stats:+.2f} NON incluso (book in sola simulazione).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|