Files
PythagorasGoal/Old/scripts/portfolios/hourly_report.py
Adriano Dal Pastro 14522262e6 chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:20:59 +00:00

268 lines
11 KiB
Python

"""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 = ["🛡️ <b>Filtri fade — stop-rate per epoca</b>"]
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)
# Epoca v1.1.26 (deploy 2026-06-11 ~21:40 UTC): gate TP_PHANTOM attivo. I close
# precedenti includono il churn da TP fantasma dell'11-06 17:32-17:58 (~24 giri,
# win-rate inquinato) -> le accuratezze "pulite" si leggono da qui in poi.
EPOCH_V1126 = "2026-06-11T21:40:00"
def collect():
closed = [] # (sleeve, reason, net_return, pnl, win, ts)
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)), ev.get("ts", "")))
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 ("📈 <b>PAPER — solo statistica, FUORI dal conto reale</b> (book | ultimo flip | status)\n<pre>"
+ "\n".join(rows) + "</pre>")
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"📊 <b>PORT06 — Report orario</b> <code>v{ver}</code>", now]
if eq is not None:
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
# 1) CHIUSI — totale storico + epoca corrente (post gate TP_PHANTOM): i
# numeri pre-v1.1.26 includono il churn fantasma e non misurano la strategia
cur = [c for c in closed if c[5] >= EPOCH_V1126]
cpos = sum(1 for c in cur if c[4])
L.append(f"\n✅ <b>CHIUSI</b>: {pos} positivi / {neg} negativi (netto fee)")
L.append(f" epoca v1.1.26+ (TP_PHANTOM attivo): {cpos}/{len(cur) - cpos}")
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("<pre>" + "\n".join(rows) + "</pre>")
# 2) IN CORSO
L.append(f"🟢 <b>IN CORSO</b>: {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("<pre>" + "\n".join(rows) + "</pre>")
# 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"💰 <b>PnL realizzato totale: €{realized:+.2f}</b>")
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()