Merge feat/hourly-telegram-report: report orario PORT06 su Telegram
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"📊 <b>PORT06 — Report orario</b>", 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>")
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user