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
+160
View File
@@ -0,0 +1,160 @@
"""L'agente che scrive l'analisi giornaliera — e i limiti dentro cui puo' scrivere.
PERCHE' UN CAMPO SUO E NON `nota`
---------------------------------
`nota` e' dell'operatore. Se un agente ci scrivesse dentro, fra sei mesi rileggendo il giornale
non si distinguerebbe piu' *chi* ha scritto cosa — che e' esattamente la distinzione per cui il
giornale esiste. L'agente ha il campo `analisi`, marcato col modello e con l'ora.
TRE LIVELLI, TENUTI SEPARATI IN PAGINA
--------------------------------------
numeri -> misurati dal feed e dal DB
Lettura -> regole dichiarate, deterministiche, ognuna col suo id
Analisi -> questo modulo: prosa di un modello, che puo' sbagliare
Nota -> l'operatore
IL RISCHIO, E LA GUARDIA
------------------------
Il modo in cui una prosa generata rovina un registro non e' l'opinione sbagliata: e' il NUMERO
inventato, che a rileggerlo sembra una misura. `numeri_non_supportati()` estrae ogni numero
dall'analisi e verifica che compaia nella pagina data in pasto al modello. Oltre
`MAX_NUMERI_LIBERI` l'analisi viene RIFIUTATA e si tiene la precedente.
E' un controllo debole per costruzione (un numero giusto copiato nel posto sbagliato lo passa):
serve contro l'invenzione, non contro il ragionamento sbagliato.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
MODELLO_DEFAULT = "claude-sonnet-5"
MAX_PAROLE = 220
MAX_NUMERI_LIBERI = 3
TIMEOUT_S = 300
ISTRUZIONI = """Sei l'analista di bordo di un libro di trading quantitativo su BTC/ETH.
Scrivi l'analisi di UNA giornata, in italiano, per un lettore che rileggera' questa pagina fra
mesi per capire cosa stava succedendo.
REGOLE VINCOLANTI:
1. Usa SOLO i numeri presenti nella pagina qui sotto. Non calcolarne di nuovi, non stimare, non
arrotondare in modo diverso. Ogni cifra che scrivi deve comparire nella pagina.
2. NON prevedere. Niente "probabilmente salira'", niente target, niente consigli operativi.
3. NON ripetere la sezione Lettura: quella e' gia' scritta. Aggiungi cio' che le regole non
possono vedere — cosa e' CAMBIATO rispetto ai giorni precedenti, cosa e' insolito, quale
lettura alternativa dello stesso dato sarebbe altrettanto compatibile.
4. Se una cosa non e' determinabile dai dati dati, dillo: "non misurabile qui" e' una risposta
accettabile e preferibile a un'inferenza.
5. Se noti qualcosa che merita un controllo umano, dillo in una frase, senza drammatizzare.
6. Massimo %d parole. Prosa, niente elenchi puntati, niente titoli.
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit, capitale ~$635.
Il progetto e' esplicitamente scettico: il suo campione e' corto e il P&L non distingue ancora
l'edge dalla fortuna. Scrivere con quella consapevolezza e' parte del compito.
""" % MAX_PAROLE
# ---------------------------------------------------------------- guardia sui numeri
_NUM = re.compile(r"-?\d[\d.,]*")
def _normalizza(tok: str) -> str:
t = tok.replace(",", "").rstrip(".").lstrip("-").lstrip("0") or "0"
if "." in t:
t = t.rstrip("0").rstrip(".") or "0"
return t
def numeri_di(testo: str) -> set[str]:
return {_normalizza(m.group()) for m in _NUM.finditer(testo)}
def numeri_non_supportati(analisi: str, fonte: str) -> list[str]:
"""Numeri che compaiono nell'analisi e NON nella pagina data al modello.
Tollera i numeri piccoli fino a 31 (giorni, conteggi, «2 su 3»): non sono cifre che possano
fabbricare una misura, e includerli produrrebbe soprattutto falsi positivi.
"""
ok = numeri_di(fonte)
fuori = []
for t in sorted(numeri_di(analisi) - ok):
try:
if abs(float(t)) <= 31 and "." not in t:
continue
except ValueError:
continue
fuori.append(t)
return fuori
def valida(analisi: str, fonte: str) -> tuple[str, list[str]]:
"""Ritorna (stato, motivi). Stato in {ok, sospetta, rifiutata}."""
motivi: list[str] = []
testo = analisi.strip()
if not testo:
return "rifiutata", ["risposta vuota"]
n_par = len(testo.split())
if n_par > MAX_PAROLE * 1.5:
motivi.append(f"troppo lunga ({n_par} parole contro un tetto di {MAX_PAROLE})")
fuori = numeri_non_supportati(testo, fonte)
if fuori:
motivi.append("numeri non presenti nella pagina: " + ", ".join(fuori))
if len(fuori) > MAX_NUMERI_LIBERI:
return "rifiutata", motivi
return ("sospetta" if motivi else "ok"), motivi
def pulisci(testo: str) -> str:
"""Toglie titoli e recinti di codice: l'analisi entra in una sezione che ha gia' il suo."""
righe = [r for r in testo.strip().splitlines() if not r.strip().startswith(("#", "```"))]
return "\n".join(righe).strip()
# ---------------------------------------------------------------- chiamata al modello
def senza_analisi(md: str) -> str:
"""Toglie la sezione `## Analisi (agente)` dalla pagina prima di darla al modello.
Senza questo il modello legge la PROPRIA uscita del giro precedente e la commenta: al primo
giro reale ha prodotto un paragrafo sull'avviso del tripwire che si era preso il giorno prima.
E' un ciclo di retroazione — ogni giorno un po' piu' di meta-commento e un po' meno analisi —
e si vede solo guardando l'uscita, perche' nessun controllo puo' distinguerlo da prosa valida.
La sezione `## Nota` resta: quella e' contesto dell'operatore, ed e' utile che la legga.
"""
fuori, salta = [], False
for r in md.splitlines():
if r.startswith("## "):
salta = r.strip().startswith("## Analisi")
if not salta:
fuori.append(r)
return "\n".join(fuori)
def costruisci_prompt(pagina: str, storico: str) -> str:
pagina = senza_analisi(pagina)
return (f"{ISTRUZIONI}\n\n"
f"=== PAGINA DI OGGI ===\n{pagina}\n\n"
f"=== GIORNI PRECEDENTI (per vedere cosa e' cambiato) ===\n{storico}\n\n"
f"Scrivi ora l'analisi, e nient'altro.")
def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
"""Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che
l'agente smette di rispondere il giornale mostrerebbe l'analisi di ieri come se fosse di oggi.
"""
try:
p = subprocess.run(
["claude", "-p", prompt, "--model", modello, "--allowed-tools", "", "--max-turns", "1"],
capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
return "", "CLI `claude` non trovata"
except subprocess.TimeoutExpired:
return "", f"timeout dopo {timeout}s"
if p.returncode != 0:
return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}"
return pulisci(p.stdout), None
+15 -2
View File
@@ -152,6 +152,8 @@ def costruisci(con, giorno: date) -> dict:
def salva(con, voce: dict, nota: str | None = None) -> None:
"""Ricalcola le sezioni misurate. NON tocca ne' `nota` (operatore) ne' `analisi` (agente):
sono le uniche due cose in pagina che un ricalcolo non puo' rigenerare."""
esistente = con.execute("SELECT nota FROM journal WHERE giorno=?", (voce["giorno"],)).fetchone()
# la nota dell'operatore NON viene mai sovrascritta da un ricalcolo automatico
nota_finale = nota if nota is not None else (esistente["nota"] if esistente else "")
@@ -363,8 +365,9 @@ def _n(v, fmt="{:+.2f}", vuoto="n/d"):
def rendi_markdown(con, voce: dict) -> str:
g, m, lb, p, s = voce["giorno"], voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
nota = con.execute("SELECT nota FROM journal WHERE giorno=?", (g,)).fetchone()
nota = nota["nota"] if nota else ""
riga = con.execute("SELECT * FROM journal WHERE giorno=?", (g,)).fetchone()
nota = (riga["nota"] if riga else "") or ""
an = dict(riga) if riga else {}
L = [f"# Giornale di bordo — {g}", "",
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*", "",
"## Mercato", "",
@@ -425,6 +428,16 @@ def rendi_markdown(con, voce: dict) -> str:
"giudizio: quello sta nella Nota.*", ""]
icona = {"info": "", "nota": "📌 ", "attenzione": "⚠️ "}
L += [f"- `[{r['id']}]` {icona[r['livello']]}{r['testo']}" for r in righe] or ["- *(nessuna regola accesa)*"]
testo = (an.get("analisi") or "").strip()
if testo:
stato = an.get("analisi_stato") or "?"
cap = (f"*Scritta da `{an.get('analisi_modello')}` il {an.get('analisi_ts')}. "
f"Prosa di un modello: puo' sbagliare, e non e' una misura. "
f"Controllo sui numeri: **{stato}**.*")
L += ["", "## Analisi (agente)", "", cap, ""]
if an.get("analisi_motivi"):
L += [f"> ⚠️ {an['analisi_motivi']}", ""]
L += [testo]
L += ["", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
return "\n".join(L)
+21
View File
@@ -97,6 +97,26 @@ CREATE TABLE IF NOT EXISTS journal (
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
"""
# Colonne aggiunte dopo la prima versione dello schema. sqlite non ha ADD COLUMN IF NOT EXISTS:
# si guarda il PRAGMA. `analisi` e' dell'AGENTE, `nota` resta dell'operatore — due campi, perche'
# a rileggere il giornale fra sei mesi si deve sapere chi ha scritto cosa.
COLONNE_TARDIVE = {
"journal": [("analisi", "TEXT NOT NULL DEFAULT ''"),
("analisi_modello", "TEXT"),
("analisi_ts", "TEXT"),
("analisi_stato", "TEXT"),
("analisi_motivi", "TEXT")],
}
def _migra(con) -> None:
for tabella, colonne in COLONNE_TARDIVE.items():
presenti = {r[1] for r in con.execute(f"PRAGMA table_info({tabella})")}
for nome, tipo in colonne:
if nome not in presenti:
con.execute(f"ALTER TABLE {tabella} ADD COLUMN {nome} {tipo}")
con.commit()
# =============================================================================================
# parsing — puro, nessun I/O: prende testo, ritorna dati
@@ -278,6 +298,7 @@ def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
con = sqlite3.connect(path)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
_migra(con)
return con