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
+17 -11
View File
@@ -1,6 +1,6 @@
# Giornale di bordo — 2026-08-23
*Scritto 2026-08-23T13:34:16+00:00. Numeri misurati; nessuna interpretazione automatica.*
*Scritto 2026-08-23T19:36:52+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
@@ -11,38 +11,44 @@
## Libro
Equity **$636.14** · nozionale lordo $258 · leva lorda 0.41x · barra dati 2026-08-23 · 14 giri
Equity **$637.36** · nozionale lordo $259 · leva lorda 0.41x · barra dati 2026-08-23 · 20 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.150 | LONG @ 75,280.0 | $+115 | $+116 | HOLD (a target) |
| **ETH** | +0.271 | LONG @ 2,436.4 | $+144 | $+142 | HOLD (a target) |
| **BTC** | +0.150 | LONG @ 75,280.0 | $+116 | $+116 | HOLD (a target) |
| **ETH** | +0.271 | LONG @ 2,436.4 | $+144 | $+143 | HOLD (a target) |
## P&L
- giorno: **$+1.14** di equity (14 letture)
- giorno: **$+2.36** di equity (20 letture)
- realizzato: +5.55 netto su 2 round-trip chiusi · 1 fill · fee 0.0243
- cumulato dall'arming: **$+38.08**
- cumulato dall'arming: **$+39.30**
## Salute
- giri di `book_execute`: 14/14 *(giorno in corso)*
- giri di `book_execute`: 20/20 *(giorno in corso)*
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+116 (TP01 +0.150, SKH01 long) e ETH $+142 (TP01 +0.271, SKH01 long).
- `[stato]` A mercato su BTC $+116 (TP01 +0.150, SKH01 long) e ETH $+143 (TP01 +0.271, SKH01 long).
- `[disaccordo]` BTC: gli orizzonti del trend **non concordano** (2/3 al rialzo) e l'esposizione TP01 e' parziale (+0.150) — e' il meccanismo, non una scelta di oggi.
- `[leva]` Leva lorda **0.41x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.59x.
- `[pnl]` Equity $+1.14 con 1 fill e 2 round-trip chiusi (realizzato $+5.55 netto).
- `[concentrazione]` 📌 **Il 102% di tutto il P&L cumulato ($+38.08) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
- `[drawdown]` 📌 Equity **-1.37%** sotto il picco ($644.98). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[pnl]` Equity $+2.36 con 1 fill e 2 round-trip chiusi (realizzato $+5.55 netto).
- `[concentrazione]` 📌 **Il 102% di tutto il P&L cumulato ($+39.30) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
- `[drawdown]` 📌 Equity **-1.18%** sotto il picco ($644.98). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 42.4 contro realizzata 30g 43.3 (sotto); DVOL al 52° pctl di un anno; IV-rank espandente 0.18 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 57.0 contro realizzata 30g 69.6 (sotto); DVOL al 44° pctl di un anno; IV-rank espandente 0.18 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **61 giorni, 12 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-sonnet-5` il 2026-08-23T19:37:20+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Giornata piatta sui prezzi (BTC +0.20%, ETH +0.17%) dopo una settimana che invece ha mosso molto (+22.94% e +29.49% a 7g): oggi il libro non ha aggiunto informazione nuova sulla direzione, ha solo tenuto le posizioni aperte il 22/08. Equity +$2.36 su 20 letture, nessun round-trip chiuso oggi (i 2 RT del giorno vengono dal conteggio cumulato della pagina, non da nuova attivita' odierna a giudicare dal confronto con ieri, dove i fill erano gia' 2). Rispetto ai giorni precedenti, il ritmo si e' fermato: dal 19 al 21/08 il libro ha macinato fino a 6 round-trip in un giorno con salti di equity a doppia cifra; il 22/08 ha invertito (-$9.00 pur con $185+$142 di posizione aperta, segno che il calo era su prezzo, non su esecuzione); oggi torna a un delta minimo. Il drawdown -1.18% dal picco resta piccolo in assoluto ma e' calcolato su letture orarie, quindi non cattura eventuali minimi intra-giorno piu' profondi. Il TP01 su BTC resta a convinzione parziale (+0.150, orizzonti in disaccordo 2/3) mentre ETH ha tutti e tre gli orizzonti allineati (+0.271): stessa direzione di ieri, nessun cambio di regime segnalato. La concentrazione del P&L nei soli ultimi 7 giorni (102% del cumulato) non e' cambiata nella sostanza da ieri — resta il fatto piu' rilevante della pagina, e con 61 giorni e 12 round-trip il campione non permette di dire se sia edge o varianza. Nessuna anomalia di salute (20/20 giri, feed SKH a 0 minuti): non ci sono elementi che richiedano un controllo umano oggi oltre a quanto gia' segnalato dal giornale stesso.
## Nota
*(vuota — campo dell'operatore)*
+28 -2
View File
@@ -4,6 +4,7 @@
uv run python scripts/live/analista.py --giorno 2026-08-21
uv run python scripts/live/analista.py --modello claude-opus-5
uv run python scripts/live/analista.py --secco # stampa e non salva
uv run python scripts/live/analista.py --no-telegram # non manda la notifica
NON tocca `nota` (dell'operatore) e non tocca nessuna sezione misurata: scrive SOLO in `analisi`.
Se il modello non risponde, l'analisi di ieri NON diventa quella di oggi: si registra l'errore
@@ -19,6 +20,7 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live import analista as A # noqa: E402
from src.live import notifier as N # noqa: E402
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
@@ -48,7 +50,28 @@ def storico(con, g: date, n: int = GIORNI_STORICO) -> str:
return "\n".join(righe)
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True) -> str:
def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str],
verbose: bool = True) -> str:
"""Manda su Telegram e REGISTRA l'esito. Tre stati, non due: inviata / non-configurato /
fallita-con-motivo. Un invio perso che non lascia traccia e' peggio di un invio non fatto:
il giorno dopo non si distingue da 'non e' successo niente'."""
testo = A.per_telegram(voce, analisi, stato, motivi)
if not N.is_configured():
esito = "non configurato"
else:
# `tentativi=3`: il tasso misurato di invii persi su questo percorso e' 6,9% (2/29).
# Su un messaggio al giorno, un tentativo solo perde ~25 messaggi in un anno.
esito = "inviata" if N.send(testo, tentativi=3) else f"FALLITA — {N.ultimo_errore()}"
con.execute("UPDATE journal SET inviata_ts=?, inviata_stato=? WHERE giorno=?",
(T.ora(), esito, g.isoformat()))
con.commit()
if verbose:
print(f" telegram: {esito} ({len(testo)} caratteri)")
return esito
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
telegram: bool = True) -> str:
voce = J.costruisci(con, g)
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
pagina = J.rendi_markdown(con, voce)
@@ -87,6 +110,8 @@ def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True
(modello, T.ora(), stato, "; ".join(motivi) or None, g.isoformat()))
con.commit()
J.scrivi_file(con, voce)
if telegram:
invia(con, g, voce, testo, stato, motivi, verbose=verbose)
return stato
@@ -94,6 +119,7 @@ if __name__ == "__main__":
con = T.connect()
g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat()))
st = scrivi(con, g, modello=_arg("--modello", A.MODELLO_DEFAULT),
secco="--secco" in sys.argv[1:], verbose="--quiet" not in sys.argv[1:])
secco="--secco" in sys.argv[1:], verbose="--quiet" not in sys.argv[1:],
telegram="--no-telegram" not in sys.argv[1:])
con.close()
sys.exit(0 if st in ("ok", "sospetta") else 1)
+52
View File
@@ -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
+33 -3
View File
@@ -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,16 +34,45 @@ 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()
ultimo = ""
for i in range(max(1, tentativi)):
try:
urllib.request.urlopen(url, data, timeout=10)
_ULTIMO_ERRORE = None
return True
except Exception:
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
+3 -1
View File
@@ -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")],
}
+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"