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
+11 -1
View File
@@ -1,6 +1,6 @@
# Giornale di bordo — 2026-08-21
*Scritto 2026-08-23T13:34:16+00:00. Numeri misurati; nessuna interpretazione automatica.*
*Scritto 2026-08-23T17:43:07+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
@@ -43,6 +43,16 @@ Equity **$644.00** · nozionale lordo $196 · leva lorda 0.30x · barra dati 202
- `[evento]` 📌 ETH: giornata a +8.19%, **2.3 deviazioni** giornaliere (sd implicita dalla RV30 = 3.62%).
- `[evidenza]` 📌 Campione a oggi: **59 giorni, 10 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-23T17:43:32+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
21 agosto — dopo dieci giorni fermi (11-18 agosto, equity ferma a 597.12, zero fill), il libro si è mosso in tre giornate consecutive: il 19 apre BTC ($+76, 1 round-trip), il 20 aggiunge ETH ($+148) e BTC sale a $+124, il 21 chiude entrambe le gambe a target (BTC $+126, ETH $+70) con $+23.37 di equity e $+18.90 realizzato su 6 round-trip. Il cumulato dall'arming passa così a $+45.94, e la pagina segnala che il 102% di quel totale viene dagli ultimi 7 giorni — cioè il periodo precedente era in perdita netta.
Il contesto di mercato spiega perché si sia mosso qualcosa dopo dieci giorni piatti: BTC +7.31% e ETH +8.19% nella giornata, con TSMOM 3/3 al rialzo su entrambi gli orizzonti per entrambi gli asset. La pagina marca la giornata a 3.2 deviazioni per BTC e 2.3 per ETH rispetto alla volatilità realizzata — un movimento ampio, non un drift ordinario. Vale la pena notare che SKH01 è long su BTC (breakout) mentre su ETH resta flat pur con TP01 a convinzione più alta (+0.291 contro +0.176): stesso regime di mercato, due letture diverse a seconda del meccanismo.
La leva resta bassa (0.30x su un tetto di 1.00x) e il feed SKH è fresco (0 minuti). Il campione dichiarato — 59 giorni, 10 round-trip — resta troppo corto perché questi tre giorni buoni dicano qualcosa sull'edge: sono compatibili sia con l'inizio di una fase favorevole al trend sia con normale rumore di un campione piccolo dopo una fase piatta.
## Nota
*(vuota — campo dell'operatore)*
+4
View File
@@ -43,5 +43,9 @@ mkdir -p logs
# Sola lettura: feed certificato, cron_book.log, trades.db. Il campo `nota` non lo tocca.
uv run python scripts/live/journal.py --giorno "$(date -u -d yesterday +%F)" --quiet || true
uv run python scripts/live/journal.py --quiet || true
# Analista di bordo: un modello scrive la prosa del giorno CHIUSO nel campo `analisi`.
# Scrive SOLO li': non tocca `nota` (operatore) ne' le sezioni misurate, e se non risponde
# la pagina resta senza analisi invece di mostrare quella di ieri. Costo: 1 chiamata/giorno.
uv run python scripts/live/analista.py --giorno "$(date -u -d yesterday +%F)" --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_daily.log 2>&1
+99
View File
@@ -0,0 +1,99 @@
"""L'analista di bordo: fa scrivere a un modello l'analisi del giorno nel campo `analisi`.
uv run python scripts/live/analista.py # oggi
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
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
e la sezione resta vuota. Sola lettura su tutto il resto; nessun ordine.
"""
from __future__ import annotations
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
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 journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
GIORNI_STORICO = 10
def _arg(flag: str, default=None):
a = sys.argv[1:]
return a[a.index(flag) + 1] if flag in a and a.index(flag) + 1 < len(a) else default
def storico(con, g: date, n: int = GIORNI_STORICO) -> str:
"""Le righe essenziali dei giorni precedenti: serve per vedere COSA E' CAMBIATO."""
righe = ["giorno equity delta fill rt posizioni"]
for i in range(n, 0, -1):
d = (g - timedelta(days=i)).isoformat()
r = con.execute("SELECT pnl, libro FROM journal WHERE giorno=?", (d,)).fetchone()
if not r:
continue
import json as _j
p, lb = _j.loads(r["pnl"] or "{}"), _j.loads(r["libro"] or "{}")
pos = " ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted((lb.get("asset") or {}).items()))
dq = p.get("delta_equity")
righe.append(f"{d} {p.get('equity_fine') or float('nan'):8,.2f} "
f"{' n/d' if dq is None else f'{dq:+8.2f}'} "
f"{p.get('fill', 0):>4} {p.get('roundtrip_chiusi', 0):>2} {pos or 'flat'}")
return "\n".join(righe)
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: 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)
st = storico(con, g)
prompt = A.costruisci_prompt(pagina, st)
# La guardia si applica a TUTTO cio' che il modello ha ricevuto, non alla sola pagina:
# i numeri del blocco storico gli sono stati dati, citarli non e' inventare. (Primo giro:
# validando sulla sola pagina il tripwire bocciava un'equity vera presa dallo storico —
# una guardia piu' stretta del contratto produce allarmi che si imparera' a ignorare.)
fonte = A.senza_analisi(pagina) + "\n" + st
testo, errore = A.interroga(prompt, modello=modello)
if errore:
stato, motivi = "errore", [errore]
testo = ""
else:
stato, motivi = A.valida(testo, fonte)
if stato == "rifiutata":
testo = ""
if verbose:
print(f" {g} modello {modello} -> {stato}"
+ (f" ({'; '.join(motivi)})" if motivi else ""))
if secco:
print("\n" + (testo or "(niente da salvare)"))
return stato
if testo:
con.execute("""UPDATE journal SET analisi=?, analisi_modello=?, analisi_ts=?,
analisi_stato=?, analisi_motivi=? WHERE giorno=?""",
(testo, modello, T.ora(), stato, "; ".join(motivi) or None, g.isoformat()))
else:
# niente analisi: si REGISTRA il perche' e NON si lascia in pagina quella di ieri
con.execute("""UPDATE journal SET analisi='', analisi_modello=?, analisi_ts=?,
analisi_stato=?, analisi_motivi=? WHERE giorno=?""",
(modello, T.ora(), stato, "; ".join(motivi) or None, g.isoformat()))
con.commit()
J.scrivi_file(con, voce)
return stato
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:])
con.close()
sys.exit(0 if st in ("ok", "sospetta") else 1)
+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
+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