9d15506b05
Audit anti-overfit su tutte le 19 sleeve (diario 2026-06-11-stability-sweep.md): - FIX BasketTrendWorker: mean(rets) sui soli asset in posizione sovrappesava N/k a paniere parziale (1 long = 0.45 del capitale invece di 0.09) -> replay -44% vs ref +42%. Ora sum(rets)/N (convenzione canonica 1/N): replay +32% vs +42% (residuo = convenzione dichiarata). Solo statistica PAPER. - XS01 PHASE-TRANCHING (gate xs01_tranche_gate: plateau K=2 E K=3 promossi, PORT06 OOS Sh 10.07->10.15 DD 1.48->1.38, FULL pari): la fase del roll e' timing-luck (Sharpe daily 1.52-2.33, DD 13.8-33% sulle 12 fasi). Worker con param tranches (default 1), 3 sub-book sfasati hold/3 su capitale comune, migrazione status legacy, last_bar_ts solo-avanti; runner forward del param; _defs tranches=3; hourly_report aggrega i sub-book; validatore esteso e PASSATO (K=1 == xsec_sim esatto, K=3 == unione fasi esatto). - Disaster-cap z sui pairs: pre-registrato e BOCCIATO su tutti i criteri (coda OOS peggiora 4/6 coppie, Sharpe -10..-49%, plateau solo del danno; 5a conferma stop-su-MR). Record pairs_zstop_research.py; pairs restano senza stop. - Audit drift: regression-lock trendmax OK (parita' 1.00000, plateau 2.5/3.0/3.5 confermato), correlazioni cross-famiglia ~0 invariate; PORT06 rolling al 19-28mo pct (normale) ma FADE 120g al 2o percentile storico -> monitor in TODO (nessun ritocco parametri). - TODO: forming-bar ROT02/TSM01 era gia' fixato (v1.1.10), item chiuso. Test: pytest 99 passed; validate_honest_workers OK; validate_xsec_worker OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.7 KiB
Python
252 lines
9.7 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)
|
|
|
|
|
|
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 "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()
|
|
held = {a: v for a, v in book.items() if v > 0}
|
|
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
|
|
L.append(f"\n✅ <b>CHIUSI</b>: {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("<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()
|