Files
PythagorasGoal/scripts/portfolios/hourly_report.py
T
Adriano Dal Pastro 62e272255b feat(live): disaster-bracket on-book sui fade reali + alert FEED_OUTAGE + osservabilita' multi-asset
Punti 5-6 dell'improvement-sweep 2026-06-06 (protezione capitale + osservabilita'):

Punto 5 — disaster bracket:
- ExecutionClient.place_disaster_sl: STOP_MARKET reduce-only a ~-30% dall'ingresso
  (trigger mark price), piazzato a ogni REAL_OPEN (MR01/MR02/MR07/DIP01) e
  cancellato in _real_close. Assicurazione outage: il poll-loop in except lascia
  le posizioni reali senza valutazione exit (ETH gap max storico 33%/1h). In
  operativita' normale non scatta mai -> 0 costo Sharpe. real_dsl_order_id
  persistito (resume-safe). Config overrides.execution.disaster_sl_pct (0.30).
- NB: set_stop_loss di cerbero-mcp e' un private/edit Deribit (solo ordini APERTI)
  -> non usabile su market fillati; il bracket e' un trigger order autonomo via
  place_order(type=stop_market). Cancel di un trigger order risponde 'untriggered'
  (= successo, verificato testnet: re-cancel -> order_not_found).
- Runner: alert Telegram FEED_OUTAGE dopo 5 poll falliti consecutivi (elenco
  posizioni reali aperte) + notifica RIPRESO con durata.

Punto 6 — osservabilita':
- in_position nei _save() di TR01/ROT02/TSM01; hourly_report: sezione MULTI-ASSET
  (book | ultimo flip | freschezza status) — prima i 3 worker erano invisibili
  (collect() filtra su event/in_position che non emettevano); esclusi dalla
  tabella IN CORSO (assume entry/bars single-leg).
- live_shadow_smoke esteso: scenari C/D SHORT (TP-resting BUY mai esercitato
  prima) + disaster bracket in tutti gli scenari.

Verifiche: 72/72 test; smoke testnet 4 scenari verdi (DSL piazzato/cancellato due
lati, zero ordini orfani sul book, conto flat); multi_asset_section renderizza sui
dati live. Diario docs/diary/2026-06-07-sweep-fixes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:36:11 +00:00

237 lines
8.9 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"
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 loss-guard Hurst: stop-rate fade PRIMA/DOPO l'attivazione (hurst_max=0.55, v1.0.0) ---
LOSSGUARD_SINCE = "2026-06-02T14:34:30"
FADE_PREFIXES = ("MR01", "MR02", "MR07") # le 3 fade con hurst_max attivo
LOSSGUARD_MIN_SAMPLE = 30
def lossguard_section() -> str:
before = [0, 0] # [closes, stops] prima dell'attivazione
after = [0, 0] # dopo
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
b = after if ev.get("ts", "") >= LOSSGUARD_SINCE else before
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 = [f"🛡️ <b>Loss-guard Hurst</b> (fade, dal {LOSSGUARD_SINCE[:16].replace('T', ' ')} UTC)"]
L.append(f" stop-rate PRIMA {rate(before):.0f}% (n={before[0]}) → DOPO {rate(after):.0f}% (n={after[0]})")
if after[0] >= LOSSGUARD_MIN_SAMPLE:
delta = rate(before) - rate(after)
L.append(f" VERDETTO (n≥{LOSSGUARD_MIN_SAMPLE}): {delta:+.0f}pp → "
f"{'✅ riduce gli stop' if delta > 0 else '⚠️ nessuna riduzione'}")
else:
L.append(f" campione DOPO {after[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:
"""Worker multi-asset (TR01/ROT02/TSM01): book corrente + eta' dell'ultimo flip
+ freschezza dello status. Prima erano INVISIBILI nel report (collect() filtra
su event/in_position che non emettevano): impossibile distinguere 'flat in
chop / risk-off by-design' da 'wiring rotto' (improvement-sweep 2026-06-06)."""
now = datetime.now(timezone.utc)
rows = []
for sp in sorted(glob.glob(str(PAPER / "*" / "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:
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>MULTI-ASSET</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()