giornale: lettura ragionata a regole dichiarate e tracciabili

Non prosa libera: dodici regole, ognuna con un id stampato accanto alla riga
che ha prodotto. Combinano solo numeri gia' presenti nella pagina, tacciono
sul non misurato e non prevedono niente. Il campo `nota` resta l'unico posto
dove puo' finire un giudizio umano.

Le regole: stato del libro e PERCHE' e' flat (componente per componente),
disaccordo fra orizzonti del trend contro esposizione TP01, incoerenza
(trend su ma libro fuori), leva contro il tetto, P&L del giorno, costo che
supera il movimento, concentrazione del cumulato, drawdown dal picco,
implicita contro realizzata col gate IV-rank di VRP01, giornata oltre 2
deviazioni, giri mancanti e feed vecchio, e la taglia del campione.

Ogni regola ha DUE test: uno che la accende e uno che la tiene spenta. Una
regola che si accende sempre non sta leggendo niente, una che non si accende
mai e' indistinguibile da una rotta.

Tre difetti corretti prima di pubblicare, tutti trovati scrivendo i test:
- il tetto di leva era RIDICHIARATO (0.5 cablato) invece che letto da
  config/live.json — quinta occorrenza dello schema che il progetto paga da
  luglio: un sorvegliante che ridichiara il proprio bersaglio continua a
  passare il giorno che il bersaglio cambia. Ora deriva, e se il config non
  si legge lo dice invece di inventare un tetto.
- l'IV-rank era il percentile a UN ANNO etichettato col nome del gate di
  VRP01, che usa un percentile ESPANDENTE. Due statistiche diverse, e oggi
  danno il verdetto OPPOSTO: 0.52 (sopra la soglia, "il sleeve venderebbe")
  contro 0.18 (sotto, sleeve fermo). Il valore giusto combacia con quanto
  gia' misurato il 30/07: 0/8 settimane passano il gate.
- la concentrazione si accendeva su $2,08 di cumulato: vera e inutile. Ora
  ha una soglia di rilevanza, o e' una riga che insegna a saltare la sezione.

62 voci rigenerate. Strategia, pesi, config INVARIATI. 675 test passano.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-23 13:36:05 +00:00
parent 10c373075c
commit 1803e0ac9f
64 changed files with 1223 additions and 63 deletions
+204 -1
View File
@@ -78,6 +78,11 @@ def metriche_mercato(giorno: date) -> dict:
d["dvol_eta_g"] = eta
if len(dv) >= 252 and eta <= 2:
d["dvol_rank_1a"] = float((dv.tail(252) < dv.iloc[-1]).mean())
if len(dv) >= 60 and eta <= 2:
# IV-rank come lo calcola VRP01 (`options_vrp_v2._ivrank`): percentile
# ESPANDENTE su tutta la storia, non una finestra di un anno. Sono due
# statistiche diverse e vanno chiamate con nomi diversi.
d["dvol_rank_exp"] = float((dv.iloc[:-1] < dv.iloc[-1]).mean())
else:
d["dvol"] = None; d["dvol_nota"] = "nessuna barra DVOL <= giorno"
else:
@@ -162,6 +167,196 @@ def salva(con, voce: dict, nota: str | None = None) -> None:
con.commit()
# =============================================================================================
# LETTURA RAGIONATA — a regole DICHIARATE, non a prosa libera
# =============================================================================================
# Ogni riga della lettura esce da una regola con un id, e ogni regola puo' solo COMBINARE numeri
# gia' presenti nella pagina. Non prevede, non consiglia, non interpreta oltre il dato.
# Le tre proprieta' che la rendono diversa da un racconto:
# (a) TRACCIABILE — l'id dice quale regola ha parlato;
# (b) FALSIFICABILE — ogni regola ha un test che la accende E uno che la tiene spenta;
# (c) MUTA sul non misurato — se il numero e' None la regola tace, non arrotonda a zero.
# Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano.
def _bucket_convinzione(tp: float) -> str:
"""TP01 media tre `np.sign()` e poi taglia a long-flat: la direzione vive in {0, 1/3, 1}.
(Il bucket 2/3 NON esiste — verificato al sorgente il 2026-08-23, §41.) Il `tp_frac` che
il libro stampa e' quella direzione MOLTIPLICATA per il vol-target, quindi non e' il bucket:
qui se ne riporta solo l'ordine di grandezza.
"""
if tp <= 0.0:
return "flat"
return "parziale" if tp < 0.60 else "piena"
def lettura(con, voce: dict) -> list[dict]:
"""Ritorna [{id, testo, livello}] — livello in {info, nota, attenzione}."""
m, lb, p, s = voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
out: list[dict] = []
def dire(rid, testo, livello="info"):
out.append(dict(id=rid, testo=testo, livello=livello))
# --- cosa il libro STA FACENDO -----------------------------------------------------------
if "errore" in lb:
dire("stato", f"Nessun giro di `book_execute`: {lb['errore']}. "
"Non e' «il libro era fermo», e' «non e' stato misurato».", "attenzione")
return out
att = {a: v for a, v in lb["asset"].items() if abs(v["posizione"]) >= 1.0}
if not att:
dire("stato", "Libro **flat** su entrambe le gambe: nessun capitale a mercato.")
motivi = []
for a, v in sorted(lb["asset"].items()):
pezzi = []
if v["tp_frac"] <= 0:
pezzi.append("TP01 a zero (trend giu' o misto)")
if v["skh_sign"] == 0:
pezzi.append("SKH01 senza breakout")
if pezzi:
motivi.append(f"{a}: " + " e ".join(pezzi))
if motivi:
dire("perche_flat", "Motivo, componente per componente — " + "; ".join(motivi) +
". Per una strategia long-flat stare fuori **e'** una decisione.")
else:
pezzi = [f"{a} ${v['posizione']:+,.0f} (TP01 {v['tp_frac']:+.3f}, "
f"SKH01 {'flat' if v['skh_sign'] == 0 else ('long' if v['skh_sign'] > 0 else 'short')})"
for a, v in sorted(att.items())]
dire("stato", "A mercato su " + " e ".join(pezzi) + ".")
# --- il segnale contro il mercato --------------------------------------------------------
for a, v in sorted(lb["asset"].items()):
d = m.get(a) or {}
su = d.get("tsmom_su")
if not su:
continue
n_su, n_tot = su
b = _bucket_convinzione(v["tp_frac"])
if 0 < n_su < n_tot and v["tp_frac"] > 0:
dire("disaccordo", f"{a}: gli orizzonti del trend **non concordano** ({n_su}/{n_tot} "
f"al rialzo) e l'esposizione TP01 e' {b} ({v['tp_frac']:+.3f}) — "
"e' il meccanismo, non una scelta di oggi.")
elif n_su == n_tot and v["tp_frac"] <= 0:
dire("incoerenza", f"{a}: tutti e {n_tot} gli orizzonti sono al rialzo ma TP01 e' a "
"zero. Da guardare: non e' lo stato atteso.", "attenzione")
# --- quanto capitale e' esposto ----------------------------------------------------------
lv = lb.get("leva_lorda")
if lv is not None:
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio
# bersaglio continua a passare il giorno che il bersaglio cambia.
try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
cap_frac = float(cfg["max_notional_per_asset_frac"])
tetto = cap_frac * len(lb["asset"])
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
f"(`max_notional_per_asset_frac` {cap_frac:.2f} x {len(lb['asset'])} "
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.")
except Exception as e:
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
f"({type(e).__name__} su config/live.json).", "attenzione")
# --- P&L del giorno, in proporzione ------------------------------------------------------
dq, fee = p.get("delta_equity"), p.get("fee", 0.0)
if dq is not None:
if p["fill"] and fee > 0 and abs(dq) < fee:
dire("fee", f"Il movimento di equity del giorno (${dq:+.2f}) e' **piu' piccolo delle "
f"fee pagate** (${fee:.4f}): la giornata l'ha decisa il costo, non il segnale.",
"nota")
elif p["fill"] == 0:
dire("pnl", f"Equity ${dq:+.2f} **senza operare**: e' mark-to-market sulle posizioni "
"gia' aperte.")
else:
dire("pnl", f"Equity ${dq:+.2f} con {p['fill']} fill e "
f"{p['roundtrip_chiusi']} round-trip chiusi (realizzato "
f"${p['realizzato_netto']:+.2f} netto).")
# --- il cumulato, e da dove viene --------------------------------------------------------
eq = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(voce["giorno"] + "T23:59:59+00:00",)).fetchall()
if len(eq) > 24:
e0, e1 = eq[0]["equity"], eq[-1]["equity"]
tot = e1 - e0
picco = max(r["equity"] for r in eq)
g7 = [r for r in eq if r["ts_utc"][:10] >= (date.fromisoformat(voce["giorno"]) -
timedelta(days=7)).isoformat()]
# Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica
# vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione.
rilevante = abs(tot) >= max(5.0, 0.01 * e1)
if rilevante and g7:
quota = (e1 - g7[0]["equity"]) / tot
if quota > 0.60:
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
"e' un evento.")
if quota > 1.0:
coda = ("Oltre il 100% perche' **il periodo precedente era in perdita**: "
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
if picco > 0:
dd = (e1 - picco) / picco
if dd < -0.005:
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). "
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
# --- volatilita' implicita contro realizzata ---------------------------------------------
try:
from src.portfolio.sleeves import VRP_CFG
soglia_ivr = float(VRP_CFG["gate_ivr"]) # derivata dal sleeve, non riscritta
except Exception:
soglia_ivr = None
for a in ASSETS:
d = m.get(a) or {}
dv, rv = d.get("dvol"), d.get("rv30_annua")
if dv is None or rv is None:
continue
segno = "sopra" if dv > rv else "sotto"
riga = f"{a}: implicita {dv:.1f} contro realizzata 30g {rv:.1f} ({segno})"
if d.get("dvol_rank_1a") is not None:
riga += f"; DVOL al {100*d['dvol_rank_1a']:.0f}° pctl di un anno"
ivr = d.get("dvol_rank_exp")
if ivr is not None and soglia_ivr is not None:
riga += (f"; IV-rank espandente {ivr:.2f} "
+ (f"**sopra** la soglia {soglia_ivr:.2f} del gate di VRP01 (a quel gate il "
"sleeve venderebbe — resta paper, regola «niente short-vol da modello»)"
if ivr > soglia_ivr else
f"sotto la soglia {soglia_ivr:.2f} del gate di VRP01 (sleeve fermo)"))
dire("vol", riga + ".")
# --- giornata fuori scala ----------------------------------------------------------------
for a in ASSETS:
d = m.get(a) or {}
r1, rv = d.get("ret_1g"), d.get("rv30_annua")
if r1 is None or not rv:
continue
sd_g = rv / math.sqrt(365)
if sd_g > 0 and abs(r1) > 2 * sd_g:
dire("evento", f"{a}: giornata a {r1:+.2f}%, **{abs(r1)/sd_g:.1f} deviazioni** "
f"giornaliere (sd implicita dalla RV30 = {sd_g:.2f}%).", "nota")
# --- salute ------------------------------------------------------------------------------
if s.get("giri_mancanti"):
dire("giri", f"**{s['giri_mancanti']} giri di `book_execute` mancanti** su "
f"{s['atteso_giri']}: in quelle ore il libro non ha ne' letto ne' eseguito.",
"attenzione")
if s.get("feed_skh_min") is not None and s["feed_skh_min"] > 30:
dire("feed", f"Feed SKH01 vecchio di {s['feed_skh_min']} min (soglia 30): la latenza "
"d'uscita di SKH01 e' quella, non ~1h.", "attenzione")
# --- quanto vale questa pagina come evidenza ---------------------------------------------
n_rt = con.execute("SELECT COUNT(*) c FROM roundtrips WHERE ts_out <= ?",
(voce["giorno"] + "T23:59:59+00:00",)).fetchone()["c"]
if len(eq) > 24:
gg = (date.fromisoformat(eq[-1]["ts_utc"][:10]) - date.fromisoformat(eq[0]["ts_utc"][:10])).days
dire("evidenza", f"Campione a oggi: **{gg} giorni, {n_rt} 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.", "nota")
return out
def _n(v, fmt="{:+.2f}", vuoto="n/d"):
return vuoto if v is None else fmt.format(v)
@@ -222,7 +417,15 @@ def rendi_markdown(con, voce: dict) -> str:
+ (f" — **{s['giri_mancanti']} mancanti**" if s["giri_mancanti"] else ""),
f"- eta' feed SKH all'ultimo giro: "
+ (f"{s['feed_skh_min']} min" if s["feed_skh_min"] is not None else "non misurata"),
"", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
""]
righe = lettura(con, voce)
L += ["## Lettura", "",
"*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra "
"e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un "
"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)*"]
L += ["", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
return "\n".join(L)