analista di bordo: un modello scrive la prosa del giorno, in un campo suo

Aggiunto il quarto livello della pagina, tenuto separato dagli altri tre:
  numeri   -> misurati dal feed e dal DB
  Lettura  -> regole deterministiche, ognuna col suo id
  Analisi  -> questo: prosa di un modello, che puo' sbagliare
  Nota     -> l'operatore

NON scrive dentro `nota`, che era la richiesta letterale: quel campo e'
dell'operatore, ed e' cio' che a rileggere il giornale fra sei mesi permette
di sapere chi ha scritto cosa. L'agente ha `analisi`, marcato col modello,
con l'ora e con l'esito del controllo sui numeri.

Gira via `claude -p` (verificato con env -i che risponda nell'ambiente nudo
di cron), una chiamata al giorno sul giorno CHIUSO, tolte le tool.

Tre guardie, una per ogni modo in cui una prosa generata rovina un registro:
- NUMERO INVENTATO: numeri_non_supportati() estrae ogni cifra dall'analisi e
  verifica che compaia in cio' che il modello ha ricevuto. Oltre tre numeri
  liberi l'analisi e' RIFIUTATA e la pagina resta senza. E' un controllo
  debole per costruzione, e lo dichiara: prende l'invenzione, non il
  ragionamento sbagliato.
- COMMENTO DI SE': senza_analisi() toglie dalla pagina la sezione dell'agente
  prima di dargliela. Al primo giro reale il modello aveva letto la propria
  uscita precedente e prodotto un paragrafo sull'avviso che si era preso il
  giorno prima — un ciclo di retroazione che in poche settimane avrebbe
  riempito il giornale di meta-commento, e che nessun controllo automatico
  puo' distinguere da prosa valida.
- ANALISI DI IERI SPACCIATA PER OGGI: se il modello non risponde, la pagina
  resta VUOTA e il perche' viene registrato (stato + motivo). Il silenzio non
  diventa continuita'.

Corretto anche un falso positivo mio: il tripwire validava sulla sola pagina
mentre il prompt include anche il blocco storico, quindi bocciava un'equity
vera. Una guardia piu' stretta del contratto produce allarmi che si impara a
ignorare.

Ogni guardia ha un test in entrambe le direzioni. 695 test passano.
Strategia, pesi, config INVARIATI. Nessun ordine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-23 17:46:29 +00:00
parent 1803e0ac9f
commit f5d9409213
7 changed files with 512 additions and 3 deletions
+202
View File
@@ -0,0 +1,202 @@
"""L'analista di bordo: cosa puo' scrivere, e cosa non deve poter rompere.
Un modello che scrive in un registro di misure ha tre modi di rovinarlo, e qui c'e' un test per
ognuno: inventare un numero, commentare se stesso, e lasciare in pagina l'analisi di ieri il
giorno che non risponde.
"""
from __future__ import annotations
import subprocess
import sys
from datetime import date
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live import analista as A # noqa: E402
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
PAGINA = """# Giornale di bordo — 2026-08-21
## Mercato
| **BTC** | $77,000.00 | +7.31% | RV30 43.9% |
## Libro
Equity **$644.00** · leva lorda 0.30x
## Lettura
- `[stato]` A mercato su BTC $+126.
## Analisi (agente)
*Scritta da un modello.*
Testo vecchio che il modello NON deve rileggere.
## Nota
Contesto scritto dall'operatore.
"""
# ------------------------------------------------- il ciclo di retroazione
def test_toglie_la_propria_analisi_precedente():
fuori = A.senza_analisi(PAGINA)
assert "Testo vecchio" not in fuori
assert "## Analisi" not in fuori
def test_ma_lascia_tutto_il_resto_compresa_la_nota_dell_operatore():
"""Controllo positivo: se togliesse anche il resto, il modello scriverebbe al buio."""
fuori = A.senza_analisi(PAGINA)
for pezzo in ("## Mercato", "## Libro", "## Lettura", "## Nota",
"Contesto scritto dall'operatore", "$77,000.00", "`[stato]`"):
assert pezzo in fuori
def test_una_pagina_senza_analisi_non_viene_toccata():
md = "# Titolo\n\n## Mercato\nx\n\n## Nota\ny\n"
assert A.senza_analisi(md) == md.rstrip("\n")
def test_il_prompt_non_contiene_l_analisi_vecchia():
p = A.costruisci_prompt(PAGINA, "storico")
assert "Testo vecchio" not in p and "storico" in p
# ------------------------------------------------- i numeri inventati
def test_scopre_un_numero_che_non_c_e():
assert A.numeri_non_supportati("L'equity e' salita a 812.40.", PAGINA) == ["812.4"]
def test_non_segnala_i_numeri_presenti_comunque_formattati():
testo = "Equity 644.00 (da $644.00), BTC a 77,000.00 e RV30 43.9%."
assert A.numeri_non_supportati(testo, PAGINA) == []
def test_tollera_i_conteggi_piccoli():
"""«2 su 3 orizzonti», «24 giri»: non sono cifre che possano fabbricare una misura."""
assert A.numeri_non_supportati("2 su 3 orizzonti, 24 giri, 10 round-trip.", PAGINA) == []
def test_una_percentuale_inventata_viene_presa():
assert "18.57" in A.numeri_non_supportati("Il mese fa +18.57%.", PAGINA)
# ------------------------------------------------- la validazione
def test_analisi_pulita_e_ok():
stato, motivi = A.valida("BTC ha fatto +7.31% e il libro era a $+126.", PAGINA)
assert stato == "ok" and motivi == []
def test_un_numero_libero_la_rende_sospetta_non_rifiutata():
stato, motivi = A.valida("Equity a 812.40 dopo il rialzo.", PAGINA)
assert stato == "sospetta" and "812.4" in motivi[0]
def test_troppi_numeri_liberi_la_rifiutano():
stato, _ = A.valida("Valori: 812.40, 913.55, 1044.10, 1155.20, 1266.30.", PAGINA)
assert stato == "rifiutata"
def test_risposta_vuota_rifiutata():
assert A.valida(" ", PAGINA)[0] == "rifiutata"
def test_pulisci_toglie_titoli_e_recinti():
assert A.pulisci("```\n# Titolo\ntesto vero\n```") == "testo vero"
# ------------------------------------------------- l'errore non si ingoia
def test_cli_assente_ritorna_errore_non_eccezione(monkeypatch):
monkeypatch.setattr(subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError()))
testo, err = A.interroga("x")
assert testo == "" and "non trovata" in err
def test_timeout_ritorna_errore(monkeypatch):
def boom(*a, **k):
raise subprocess.TimeoutExpired(cmd="claude", timeout=1)
monkeypatch.setattr(subprocess, "run", boom)
assert "timeout" in A.interroga("x")[1]
def test_uscita_non_zero_ritorna_errore(monkeypatch):
class R:
returncode, stdout, stderr = 2, "", "boom"
monkeypatch.setattr(subprocess, "run", lambda *a, **k: R())
assert "uscita 2" in A.interroga("x")[1]
# ------------------------------------------------- il DB: cosa scrive e cosa non tocca
def _prepara(db, g="2026-08-21"):
voce = dict(giorno=g, ts_scritto=T.ora(), mercato={}, libro={"errore": "x", "giri": 0},
pnl=J.pnl_giorno(db, date.fromisoformat(g)),
salute=dict(giri_book=0, feed_skh_min=None, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=24))
J.salva(db, voce, nota="nota dell'operatore")
return voce
def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
import analista as CLI
_prepara(db)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi valida senza numeri nuovi.", None))
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
r = db.execute("SELECT nota, analisi, analisi_stato FROM journal WHERE giorno='2026-08-21'").fetchone()
assert r["nota"] == "nota dell'operatore"
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path):
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
import analista as CLI
_prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi di ieri.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("", "rete KO"))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
r = db.execute("SELECT analisi, analisi_stato, analisi_motivi FROM journal "
"WHERE giorno='2026-08-21'").fetchone()
assert r["analisi"] == "" # niente in pagina
assert r["analisi_stato"] == "errore" and "rete KO" in r["analisi_motivi"] # ma il perche' c'e'
def test_un_ricalcolo_del_giornale_non_cancella_l_analisi(db, monkeypatch, tmp_path):
import analista as CLI
voce = _prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi da conservare.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
J.salva(db, voce) # il cron rigenera le sezioni misurate
r = db.execute("SELECT analisi, nota FROM journal WHERE giorno='2026-08-21'").fetchone()
assert r["analisi"] == "Analisi da conservare." and r["nota"] == "nota dell'operatore"
def test_il_markdown_dichiara_modello_ora_e_esito_del_controllo(db, monkeypatch, tmp_path):
import analista as CLI
voce = _prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Testo dell'agente.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="modello-finto", verbose=False)
md = J.rendi_markdown(db, voce)
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