"""Report orario PORT06 -> Telegram. Legge lo stato persistito del paper trader a portafoglio (data/portfolio_paper/*/ + data/portfolios/PORT06/status.json) e invia su Telegram: 1) trade CHIUSI: positivi/negativi (netto fee) con breakdown per motivo e PnL; 2) trade IN CORSO (posizioni aperte); 3) PnL realizzato totale + equity mark-to-market. Eseguibile standalone (es. da cron orario): cd /opt/docker/PythagorasGoal && uv run python scripts/portfolios/hourly_report.py Carica .env da solo (cron non eredita l'env del container). Legge file world-readable scritti dal container; non tocca lo stato del trader. """ from __future__ import annotations import json import glob from collections import defaultdict from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PAPER = ROOT / "data" / "portfolio_paper" PAPER_STATS = ROOT / "data" / "portfolio_paper_stats" # sleeve PAPER fuori dal conto reale PORT_STATUS = ROOT / "data" / "portfolios" / "PORT06" / "status.json" def _load_env(): """Carica TELEGRAM_* da .env nell'os.environ (cron non li ha).""" import os envf = ROOT / ".env" if not envf.exists(): return for line in envf.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip()) def _short(wid: str) -> str: """SH01_shape_ml__BTC__1h -> SH01/BTC ; PR01_..._ETH_SOL__1h -> PR01/ETH_SOL.""" parts = wid.split("__") code = parts[0].split("_")[0] tag = parts[1] if len(parts) > 1 else "" return f"{code}/{tag}" if tag else code # --- monitor filtri fade: stop-rate per epoca di config --- # epoche: PRE (nessun filtro) -> HURST (loss-guard 0.55, v1.0.0) -> TREND (swap # hurst->trend_max=3.0, 2026-06-07: gate trendmax_port06_impact, hurst ridondante # post-EXIT-16). Confronta lo stop-rate live di ogni epoca col backtest. LOSSGUARD_SINCE = "2026-06-02T14:34:30" TRENDSWAP_SINCE = "2026-06-07T10:10:00" FADE_PREFIXES = ("MR01", "MR02", "MR07") LOSSGUARD_MIN_SAMPLE = 30 def lossguard_section() -> str: pre = [0, 0] # [closes, stops] nessun filtro hurst = [0, 0] # epoca loss-guard Hurst trend = [0, 0] # epoca filtro trend (config attuale) for sp in glob.glob(str(PAPER / "*" / "status.json")): wid = Path(sp).parent.name if not wid.startswith(FADE_PREFIXES): continue tp = Path(sp).parent / "trades.jsonl" if not tp.exists(): continue for line in tp.read_text().splitlines(): if not line.strip(): continue ev = json.loads(line) if ev.get("event") != "CLOSE": continue ts = ev.get("ts", "") b = trend if ts >= TRENDSWAP_SINCE else hurst if ts >= LOSSGUARD_SINCE else pre b[0] += 1 if ev.get("reason") == "stop_loss": b[1] += 1 def rate(b): return b[1] / b[0] * 100 if b[0] else 0.0 L = ["🛡️ Filtri fade — stop-rate per epoca"] L.append(f" PRE {rate(pre):.0f}% (n={pre[0]}) → HURST {rate(hurst):.0f}% (n={hurst[0]})" f" → TREND {rate(trend):.0f}% (n={trend[0]})") if trend[0] >= LOSSGUARD_MIN_SAMPLE: delta = rate(pre) - rate(trend) L.append(f" VERDETTO epoca TREND (n≥{LOSSGUARD_MIN_SAMPLE}): {delta:+.0f}pp vs PRE → " f"{'✅ riduce gli stop' if delta > 0 else '⚠️ nessuna riduzione'}") else: L.append(f" campione TREND {trend[0]}/{LOSSGUARD_MIN_SAMPLE} → verdetto rimandato") return "\n".join(L) def collect(): closed = [] # (sleeve, reason, net_return, pnl, win) open_pos = [] # dict per posizione aperta realized = 0.0 for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))): d = Path(sp).parent wid = d.name st = json.loads(Path(sp).read_text()) tp = d / "trades.jsonl" if tp.exists(): for line in tp.read_text().splitlines(): if not line.strip(): continue ev = json.loads(line) if ev.get("event") != "CLOSE": continue nr = ev.get("net_return", 0.0) pnl = ev.get("pnl", 0.0) realized += pnl # win = flag del worker (col real-truth segue il PnL REALE; net_return # resta il sim diagnostico: sui TP fantasma da spike testnet diceva # 26/0 mentre il reale era 11/15). Fallback nr>0 per eventi storici. closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, bool(ev.get("win", nr > 0)))) if "positions" in st or "weights" in st: continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata if st.get("in_position"): open_pos.append({ "sleeve": _short(wid), "dir": st.get("direction", 0), "entry": st.get("entry_price") or st.get("entry_a"), "entry_b": st.get("entry_b"), "bars": st.get("bars_held", 0), "cap": st.get("capital", 0.0), }) return closed, open_pos, realized def multi_asset_section() -> str: """Sleeve PAPER (TR01/ROT02/TSM01): SOLO statistica, FUORI dal conto reale dei €2000 (2026-06-08). Vivono in data/portfolio_paper_stats/ con capitale nozionale fisso, raccolti per eventuali future implementazioni reali. Book corrente + eta' ultimo flip + freschezza.""" now = datetime.now(timezone.utc) rows = [] for sp in sorted(glob.glob(str(PAPER_STATS / "*" / "status.json"))): d = Path(sp).parent st = json.loads(Path(sp).read_text()) book = st.get("positions") if "positions" in st else st.get("weights") if book is None and "books" in st: # XS01 tranched (2026-06-11): aggrega i sub-book in un book medio per-asset k = max(1, len(st["books"])) book = {} for b in st["books"]: for a, v in (b.get("weights") or {}).items(): book[a] = book.get(a, 0.0) + v / k if book is None: continue # single-leg/pairs: gia' coperti da collect() # abs(): il book XS01 e' long/short market-neutral — col filtro v>0 le # gambe SHORT sparivano e un book net-short appariva "flat" nel report held = {a: v for a, v in book.items() if abs(v) > 1e-4} flip = "mai" tp = d / "trades.jsonl" if tp.exists(): lines = [ln for ln in tp.read_text().splitlines() if ln.strip()] if lines: ts = json.loads(lines[-1]).get("ts", "") if ts: days = (now - datetime.fromisoformat(ts)).days flip = f"{days}g fa" fresh = "?" lu = st.get("ts") or st.get("last_update") if lu: h = (now - datetime.fromisoformat(lu)).total_seconds() / 3600 fresh = "OK" if h < 2 else f"STALE {h:.0f}h" code = d.name.split("__")[0].split("_")[0] # TR01_basket__... -> TR01 hb = ",".join(f"{a}:{v:.2f}" for a, v in sorted(held.items())) if held else "flat" rows.append(f"{code:<7}{hb:<26}{flip:>8} {fresh}") if not rows: return "" return ("📈 PAPER — solo statistica, FUORI dal conto reale (book | ultimo flip | status)\n
"
+ "\n".join(rows) + "")
def build_report() -> str:
closed, open_pos, realized = collect()
pos = sum(1 for c in closed if c[4])
neg = len(closed) - pos
# breakdown per motivo
by_reason = defaultdict(lambda: [0, 0, 0.0]) # reason -> [win, loss, pnl]
for _, reason, _, pnl, win in closed:
r = by_reason[reason]
r[0 if win else 1] += 1
r[2] += pnl
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
try:
ver = (ROOT / "VERSION").read_text().strip()
except Exception:
ver = "?"
eq = dd = cap = None
if PORT_STATUS.exists():
ps = json.loads(PORT_STATUS.read_text())
eq, cap, dd = ps.get("equity"), ps.get("total_capital"), ps.get("max_dd")
L = [f"📊 PORT06 — Report orario v{ver}", now]
if eq is not None:
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
# 1) CHIUSI
L.append(f"\n✅ CHIUSI: {pos} positivi / {neg} negativi (netto fee)")
rows = [f"{'motivo':<12}{'✅':>3}{'❌':>4}{'PnL€':>9}"]
for reason, (w, l, pnl) in sorted(by_reason.items(), key=lambda x: x[1][2]):
rows.append(f"{reason:<12}{w:>3}{l:>4}{pnl:>+9.2f}")
L.append("" + "\n".join(rows) + "") # 2) IN CORSO L.append(f"🟢 IN CORSO: {len(open_pos)} posizioni") if open_pos: rows = [f"{'sleeve':<14}{'d':<2}{'barre':>6} {'entry'}"] for p in sorted(open_pos, key=lambda x: x["sleeve"]): d = "L" if p["dir"] == 1 else "S" if p["dir"] == -1 else "-" entry = p["entry"] es = f"{entry:.6g}" if isinstance(entry, (int, float)) else str(entry) if p["entry_b"]: es = f"{entry:.6g}/{p['entry_b']:.6g}" # coppia: 2 gambe rows.append(f"{p['sleeve']:<14}{d:<2}{p['bars']:>6} {es}") L.append("
" + "\n".join(rows) + "") # 2a) worker multi-asset (TR01/ROT02/TSM01) mas = multi_asset_section() if mas: L.append(mas) # 2b) monitor loss-guard L.append(lossguard_section()) # 3) TOTALE L.append(f"💰 PnL realizzato totale: €{realized:+.2f}") if eq is not None: unreal = eq - cap L.append(f" equity mark-to-market: €{eq:.2f} (non realizz. €{unreal:+.2f})") return "\n".join(L) def main(): _load_env() import sys sys.path.insert(0, str(ROOT)) from src.live.telegram_notifier import send_telegram report = build_report() print(report) ok = send_telegram(report) print("\n[telegram]", "inviato" if ok else "NON inviato (token/chat mancanti o errore rete)") if __name__ == "__main__": main()