From 99efc7a042e7cfa5e4937d51f5263e2a0df2e1aa Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 1 Jun 2026 14:02:37 +0000 Subject: [PATCH] feat(report): report orario PORT06 su Telegram Script standalone che 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) tabella trade IN CORSO (posizioni aperte, single e pairs a 2 gambe); (3) PnL realizzato totale + equity mark-to-market (con non realizzato). Carica .env da solo (cron non eredita l'env del container), legge file world-readable scritti dal container, non tocca lo stato del trader. Riusa send_telegram di telegram_notifier. Schedulazione: crontab host orario (minuto 0): 0 * * * * uv run --project /opt/docker/PythagorasGoal python \ scripts/portfolios/hourly_report.py (uv run --project + path assoluto: nessun cd necessario.) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/portfolios/hourly_report.py | 144 ++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 scripts/portfolios/hourly_report.py diff --git a/scripts/portfolios/hourly_report.py b/scripts/portfolios/hourly_report.py new file mode 100644 index 0000000..a5dd005 --- /dev/null +++ b/scripts/portfolios/hourly_report.py @@ -0,0 +1,144 @@ +"""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" +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 + + +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 + closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, nr > 0)) + 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 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") + 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", 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) + "
") + + # 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()