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:
@@ -1,6 +1,6 @@
|
|||||||
# Giornale di bordo — 2026-08-21
|
# 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
|
## 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%).
|
- `[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.
|
- `[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
|
## Nota
|
||||||
|
|
||||||
*(vuota — campo dell'operatore)*
|
*(vuota — campo dell'operatore)*
|
||||||
|
|||||||
@@ -43,5 +43,9 @@ mkdir -p logs
|
|||||||
# Sola lettura: feed certificato, cron_book.log, trades.db. Il campo `nota` non lo tocca.
|
# 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 --giorno "$(date -u -d yesterday +%F)" --quiet || true
|
||||||
uv run python scripts/live/journal.py --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') ====="
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
} >> logs/cron_daily.log 2>&1
|
} >> logs/cron_daily.log 2>&1
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -152,6 +152,8 @@ def costruisci(con, giorno: date) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def salva(con, voce: dict, nota: str | None = None) -> None:
|
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()
|
esistente = con.execute("SELECT nota FROM journal WHERE giorno=?", (voce["giorno"],)).fetchone()
|
||||||
# la nota dell'operatore NON viene mai sovrascritta da un ricalcolo automatico
|
# 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 "")
|
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:
|
def rendi_markdown(con, voce: dict) -> str:
|
||||||
g, m, lb, p, s = voce["giorno"], voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
|
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()
|
riga = con.execute("SELECT * FROM journal WHERE giorno=?", (g,)).fetchone()
|
||||||
nota = nota["nota"] if nota else ""
|
nota = (riga["nota"] if riga else "") or ""
|
||||||
|
an = dict(riga) if riga else {}
|
||||||
L = [f"# Giornale di bordo — {g}", "",
|
L = [f"# Giornale di bordo — {g}", "",
|
||||||
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*", "",
|
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*", "",
|
||||||
"## Mercato", "",
|
"## Mercato", "",
|
||||||
@@ -425,6 +428,16 @@ def rendi_markdown(con, voce: dict) -> str:
|
|||||||
"giudizio: quello sta nella Nota.*", ""]
|
"giudizio: quello sta nella Nota.*", ""]
|
||||||
icona = {"info": "", "nota": "📌 ", "attenzione": "⚠️ "}
|
icona = {"info": "", "nota": "📌 ", "attenzione": "⚠️ "}
|
||||||
L += [f"- `[{r['id']}]` {icona[r['livello']]}{r['testo']}" for r in righe] or ["- *(nessuna regola accesa)*"]
|
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)*"), ""]
|
L += ["", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
|
||||||
return "\n".join(L)
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ CREATE TABLE IF NOT EXISTS journal (
|
|||||||
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
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
|
# 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 = sqlite3.connect(path)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
con.executescript(SCHEMA)
|
con.executescript(SCHEMA)
|
||||||
|
_migra(con)
|
||||||
return con
|
return con
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user