diff --git a/docs/journal/2026-08-23.md b/docs/journal/2026-08-23.md index e4d6906..dcd6574 100644 --- a/docs/journal/2026-08-23.md +++ b/docs/journal/2026-08-23.md @@ -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)* diff --git a/scripts/live/analista.py b/scripts/live/analista.py index 200714d..b16f3c0 100644 --- a/scripts/live/analista.py +++ b/scripts/live/analista.py @@ -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) diff --git a/src/live/analista.py b/src/live/analista.py index d1a79fb..0ab6086 100644 --- a/src/live/analista.py +++ b/src/live/analista.py @@ -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"📓 Giornale — {_b(voce['giorno'])}"] + r.append(" equity " + (f"${eq:,.2f}" 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"\1", corpo) # il grassetto del giornale + if stato == "sospetta": + testo += "\n\n⚠️ controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "" + testo += "\n\n" + corpo + else: + testo += ("\n\n⚠️ analisi non disponibile (" + _b(stato) + "): " + + _b("; ".join(motivi or ["motivo non registrato"])) + "") + if len(testo) > LIMITE_TELEGRAM: + taglio = "\n\n[…tagliato: la voce intera e' in docs/journal/%s.md]" % voce["giorno"] + testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio + return testo diff --git a/src/live/notifier.py b/src/live/notifier.py index 460068c..ece79a4 100644 --- a/src/live/notifier.py +++ b/src/live/notifier.py @@ -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: diff --git a/src/live/tradesdb.py b/src/live/tradesdb.py index 33e67b7..2fdf07a 100644 --- a/src/live/tradesdb.py +++ b/src/live/tradesdb.py @@ -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")], } diff --git a/tests/test_analista.py b/tests/test_analista.py index 0e83277..eb6d9bc 100644 --- a/tests/test_analista.py +++ b/tests/test_analista.py @@ -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 non ha operato & resta a target. **importante**", + "ok", []) + assert "<b>" in m # il tag del modello e' NEUTRALIZZATO + assert "&" in m + assert "importante" 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"