diff --git a/docs/journal/2026-08-21.md b/docs/journal/2026-08-21.md index f9e7329..08aac39 100644 --- a/docs/journal/2026-08-21.md +++ b/docs/journal/2026-08-21.md @@ -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)* diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index 6aa7a7d..c2c9af6 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -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 diff --git a/scripts/live/analista.py b/scripts/live/analista.py new file mode 100644 index 0000000..200714d --- /dev/null +++ b/scripts/live/analista.py @@ -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) diff --git a/src/live/analista.py b/src/live/analista.py new file mode 100644 index 0000000..d1a79fb --- /dev/null +++ b/src/live/analista.py @@ -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 diff --git a/src/live/journal.py b/src/live/journal.py index 19ff699..48dd17e 100644 --- a/src/live/journal.py +++ b/src/live/journal.py @@ -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) diff --git a/src/live/tradesdb.py b/src/live/tradesdb.py index 58b8816..33e67b7 100644 --- a/src/live/tradesdb.py +++ b/src/live/tradesdb.py @@ -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 diff --git a/tests/test_analista.py b/tests/test_analista.py new file mode 100644 index 0000000..0e83277 --- /dev/null +++ b/tests/test_analista.py @@ -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