analista: manda l'analisi giornaliera su Telegram, con l'esito registrato
La notifica porta una testata coi numeri che si leggono senza aprire nulla
(equity, delta del giorno, cumulato, posizioni, leva, fill e round-trip) e
sotto l'analisi del modello.
Il trasporto Telegram e' pero' il punto singolo di guasto gia' misurato in
questo progetto: un tentativo, nessun retry, esito mai registrato, 6,9% di
invii persi (2/29). Su un messaggio al giorno sono ~25 messaggi persi
all'anno, quindi qui sono state fatte due delle tre riparazioni dichiarate
il 2026-08-23 e mai eseguite:
(a) `send(text, tentativi=1)` — retry OPZIONALE con backoff. Il default resta
1, cosi' il comportamento e' invariato per tutti i chiamanti esistenti:
alzarlo per tutti cambierebbe la latenza degli allarmi di venue_watch e
book_execute su un percorso con soldi veri, e non e' una modifica da fare
di straforo dentro un'altra funzionalita'. L'analista chiede 3.
(b) `ultimo_errore()` — il motivo si registra nel punto in cui l'eccezione
veniva ingoiata, e NON sopravvive a un invio riuscito. Regola gia'
codificata il 29/07 su un altro percorso e mai applicata al notifier.
La terza (marcare `alerted=True` solo a invio riuscito in venue_watch) cambia
il comportamento degli allarmi e resta una decisione dell'operatore.
L'esito finisce nel DB in tre stati: inviata / non configurato / FALLITA col
motivo. Un invio perso che non lascia traccia, il giorno dopo, non si
distingue da "non e' successo niente".
E se l'analisi manca, il messaggio parte lo stesso dicendo PERCHE': senza
quel ramo un guasto del modello si leggerebbe come una giornata senza nulla
da dire. Taglio a 4096 caratteri dichiarato, mai silenzioso; HTML del modello
neutralizzato.
708 test passano. Strategia, pesi, config INVARIATI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ serve contro l'invenzione, non contro il ragionamento sbagliato.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -158,3 +159,54 @@ def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
|
||||
if p.returncode != 0:
|
||||
return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}"
|
||||
return pulisci(p.stdout), None
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# IL MESSAGGIO TELEGRAM
|
||||
# =============================================================================================
|
||||
# Telegram taglia a 4096 caratteri. Un taglio silenzioso su un'analisi e' peggio di un'analisi
|
||||
# assente: si legge come un pensiero finito che finito non e'. Qui il taglio si DICHIARA.
|
||||
|
||||
LIMITE_TELEGRAM = 4096
|
||||
|
||||
|
||||
def _b(x) -> str:
|
||||
return html.escape(str(x), quote=False)
|
||||
|
||||
|
||||
def testata(voce: dict) -> str:
|
||||
"""Le tre righe che si leggono dalla notifica senza aprire nulla."""
|
||||
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
||||
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
||||
eq = p.get("equity_fine")
|
||||
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
||||
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
||||
+ ("" if dq is None else f" ({dq:+.2f} oggi)")
|
||||
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"))
|
||||
if "errore" not in lb and lb.get("asset"):
|
||||
pos = " · ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted(lb["asset"].items()))
|
||||
r.append(f" {pos} · leva {lb.get('leva_lorda') or 0:.2f}x")
|
||||
r.append(f" {p.get('fill', 0)} fill · {p.get('roundtrip_chiusi', 0)} round-trip chiusi")
|
||||
return "\n".join(r)
|
||||
|
||||
|
||||
def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None = None) -> str:
|
||||
"""Testata coi numeri + analisi. Se l'analisi manca, il messaggio DICE perche'.
|
||||
|
||||
Il silenzio non e' una notifica: senza questo ramo, un guasto del modello si leggerebbe
|
||||
come 'oggi non e' successo niente'.
|
||||
"""
|
||||
testo = testata(voce)
|
||||
if analisi.strip():
|
||||
corpo = _b(analisi.strip())
|
||||
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
|
||||
if stato == "sospetta":
|
||||
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
|
||||
testo += "\n\n" + corpo
|
||||
else:
|
||||
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
||||
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
|
||||
if len(testo) > LIMITE_TELEGRAM:
|
||||
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
|
||||
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
|
||||
return testo
|
||||
|
||||
+38
-8
@@ -8,6 +8,7 @@ Test della config: uv run python -m src.live.notifier "messaggio di prova"
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -33,17 +34,46 @@ def _cfg() -> tuple[str, str]:
|
||||
return tok, chat
|
||||
|
||||
|
||||
def send(text: str) -> bool:
|
||||
# Ultimo esito d'invio, per chi vuole SAPERE se il messaggio e' partito. Fino al 2026-08-23
|
||||
# `send()` faceva un tentativo e ingoiava l'eccezione senza scriverla da nessuna parte: quando
|
||||
# serviva sapere se un allarme era arrivato, l'informazione non esisteva (tasso misurato di
|
||||
# invii falliti: 6,9%, 2 su 29). Regola gia' codificata il 29/07 su un altro percorso e mai
|
||||
# applicata qui: *se un errore si ingoia per non bloccare, si registra nel punto in cui lo si ingoia.*
|
||||
_ULTIMO_ERRORE: str | None = None
|
||||
|
||||
|
||||
def ultimo_errore() -> str | None:
|
||||
"""Motivo dell'ultimo invio fallito, o None se l'ultimo e' riuscito. Non sopravvive a un
|
||||
invio riuscito: una causa vecchia accanto a un invio nuovo manda sulla pista sbagliata."""
|
||||
return _ULTIMO_ERRORE
|
||||
|
||||
|
||||
def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool:
|
||||
"""`tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti.
|
||||
|
||||
Chi vuole il retry lo chiede: alzarlo per tutti cambierebbe la latenza degli allarmi di
|
||||
`venue_watch` e `book_execute` su un percorso con soldi veri, e non e' una modifica da fare
|
||||
di straforo dentro un'altra funzionalita'.
|
||||
"""
|
||||
global _ULTIMO_ERRORE
|
||||
tok, chat = _cfg()
|
||||
if not tok or not chat:
|
||||
_ULTIMO_ERRORE = "config assente (TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID)"
|
||||
return False
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{tok}/sendMessage"
|
||||
data = urllib.parse.urlencode({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode()
|
||||
urllib.request.urlopen(url, data, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
url = f"https://api.telegram.org/bot{tok}/sendMessage"
|
||||
data = urllib.parse.urlencode({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode()
|
||||
ultimo = ""
|
||||
for i in range(max(1, tentativi)):
|
||||
try:
|
||||
urllib.request.urlopen(url, data, timeout=10)
|
||||
_ULTIMO_ERRORE = None
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 — si registra, non si nasconde
|
||||
ultimo = f"{type(e).__name__}: {str(e)[:120]}"
|
||||
if i + 1 < max(1, tentativi):
|
||||
time.sleep(pausa_s * (i + 1)) # backoff lineare
|
||||
_ULTIMO_ERRORE = f"{max(1, tentativi)} tentativi falliti — {ultimo}"
|
||||
return False
|
||||
|
||||
|
||||
def notify(title: str, data: dict | None = None) -> bool:
|
||||
|
||||
@@ -105,7 +105,9 @@ COLONNE_TARDIVE = {
|
||||
("analisi_modello", "TEXT"),
|
||||
("analisi_ts", "TEXT"),
|
||||
("analisi_stato", "TEXT"),
|
||||
("analisi_motivi", "TEXT")],
|
||||
("analisi_motivi", "TEXT"),
|
||||
("inviata_ts", "TEXT"),
|
||||
("inviata_stato", "TEXT")],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user