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:
Adriano Dal Pastro
2026-08-23 19:40:56 +00:00
parent 7456be1ce5
commit 782c0ce4bc
6 changed files with 277 additions and 22 deletions
+139
View File
@@ -200,3 +200,142 @@ def test_il_markdown_dichiara_modello_ora_e_esito_del_controllo(db, monkeypatch,
assert "## Analisi (agente)" in md and "modello-finto" in md
assert "puo' sbagliare" in md and "Controllo sui numeri: **ok**" in md
assert md.index("## Analisi") < md.index("## Nota") # l'agente prima, l'operatore in fondo
# =============================================================================================
# TELEGRAM — il trasporto e' il punto singolo di guasto gia' misurato (6,9% di invii persi)
# =============================================================================================
def _voce_piena():
return dict(giorno="2026-08-23", ts_scritto=T.ora(), mercato={},
libro=dict(giri=20, equity=637.36, leva_lorda=0.407,
asset={"BTC": dict(tp_frac=0.15, skh_sign=1, skh_entry=75280.0,
target=116.0, posizione=116.0, azione="HOLD"),
"ETH": dict(tp_frac=0.271, skh_sign=1, skh_entry=2436.4,
target=144.0, posizione=143.0, azione="HOLD")}),
pnl=dict(equity_fine=637.36, delta_equity=2.36, cumulato_da_arming=39.30,
fill=1, roundtrip_chiusi=2, realizzato_netto=5.55, fee=0.0243,
letture_equity=20),
salute=dict(giri_book=20, feed_skh_min=0, atteso_giri=20,
giorno_in_corso=True, giri_mancanti=0))
def test_la_testata_porta_i_numeri_che_si_leggono_dalla_notifica():
t = A.testata(_voce_piena())
assert "$637.36" in t and "+2.36" in t and "+39.30" in t
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
m = A.per_telegram(_voce_piena(), "Il libro <b>non</b> ha operato & resta a target. **importante**",
"ok", [])
assert "&lt;b&gt;" in m # il tag del modello e' NEUTRALIZZATO
assert "&amp;" in m
assert "<b>importante</b>" in m # il grassetto del giornale, invece, passa
def test_analisi_assente_il_messaggio_DICE_perche(_=None):
m = A.per_telegram(_voce_piena(), "", "errore", ["rete KO"])
assert "non disponibile" in m and "rete KO" in m
# il silenzio non e' una notifica: senza questo ramo un guasto si legge come "niente da dire"
def test_analisi_sospetta_lo_dichiara_nel_messaggio():
m = A.per_telegram(_voce_piena(), "Testo.", "sospetta", ["numeri non presenti: 812.4"])
assert "sospetta" in m and "812.4" in m
def test_il_taglio_a_4096_si_DICHIARA():
m = A.per_telegram(_voce_piena(), "x" * 9000, "ok", [])
assert len(m) <= A.LIMITE_TELEGRAM
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
def test_un_messaggio_normale_non_viene_toccato():
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
assert "tagliato" not in m and m.endswith("Analisi breve.")
# ------------------------------------------------- il notifier
def test_send_resta_a_UN_tentativo_per_i_chiamanti_esistenti(monkeypatch):
"""Alzare il retry per tutti cambierebbe la latenza degli allarmi del libro live."""
from src.live import notifier as N
n = {"c": 0}
def falla(*a, **k):
n["c"] += 1
raise OSError("giu'")
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
monkeypatch.setattr(N.urllib.request, "urlopen", falla)
assert N.send("x") is False and n["c"] == 1
def test_chi_lo_chiede_ottiene_il_retry_e_il_motivo(monkeypatch):
from src.live import notifier as N
n = {"c": 0}
def falla(*a, **k):
n["c"] += 1
raise OSError("giu'")
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
monkeypatch.setattr(N.urllib.request, "urlopen", falla)
monkeypatch.setattr(N.time, "sleep", lambda s: None)
assert N.send("x", tentativi=3) is False and n["c"] == 3
assert "3 tentativi" in N.ultimo_errore() and "OSError" in N.ultimo_errore()
def test_il_motivo_non_sopravvive_a_un_invio_riuscito(monkeypatch):
"""Una causa vecchia accanto a un invio nuovo manda sulla pista sbagliata (lezione 29/07)."""
from src.live import notifier as N
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
monkeypatch.setattr(N.time, "sleep", lambda s: None)
monkeypatch.setattr(N.urllib.request, "urlopen",
lambda *a, **k: (_ for _ in ()).throw(OSError("giu'")))
N.send("x", tentativi=2)
assert N.ultimo_errore() is not None
monkeypatch.setattr(N.urllib.request, "urlopen", lambda *a, **k: None)
assert N.send("x") is True and N.ultimo_errore() is None
def test_config_assente_e_un_motivo_diverso_da_rete_KO(monkeypatch):
from src.live import notifier as N
monkeypatch.setattr(N, "_cfg", lambda: ("", ""))
assert N.send("x") is False
assert "config assente" in N.ultimo_errore()
# ------------------------------------------------- l'esito finisce nel DB
def test_l_esito_dell_invio_viene_registrato(db, monkeypatch, tmp_path):
import analista as CLI
from src.live import notifier as N
_prepara(db, "2026-08-23")
monkeypatch.setattr(N, "is_configured", lambda: True)
monkeypatch.setattr(N, "send", lambda *a, **k: True)
CLI.invia(db, date(2026, 8, 23), _voce_piena(), "testo", "ok", [], verbose=False)
r = db.execute("SELECT inviata_stato, inviata_ts FROM journal WHERE giorno='2026-08-23'").fetchone()
assert r["inviata_stato"] == "inviata" and r["inviata_ts"]
def test_un_invio_fallito_lascia_il_MOTIVO_nel_DB(db, monkeypatch, tmp_path):
"""Un invio perso che non lascia traccia il giorno dopo non si distingue da 'niente da dire'."""
import analista as CLI
from src.live import notifier as N
_prepara(db, "2026-08-23")
monkeypatch.setattr(N, "is_configured", lambda: True)
monkeypatch.setattr(N, "send", lambda *a, **k: False)
monkeypatch.setattr(N, "ultimo_errore", lambda: "3 tentativi falliti — OSError: giu'")
CLI.invia(db, date(2026, 8, 23), _voce_piena(), "testo", "ok", [], verbose=False)
st = db.execute("SELECT inviata_stato FROM journal WHERE giorno='2026-08-23'").fetchone()[0]
assert st.startswith("FALLITA") and "OSError" in st
def test_telegram_non_configurato_non_e_un_fallimento(db, monkeypatch):
import analista as CLI
from src.live import notifier as N
_prepara(db, "2026-08-23")
monkeypatch.setattr(N, "is_configured", lambda: False)
CLI.invia(db, date(2026, 8, 23), _voce_piena(), "testo", "ok", [], verbose=False)
st = db.execute("SELECT inviata_stato FROM journal WHERE giorno='2026-08-23'").fetchone()[0]
assert st == "non configurato"