Files
PythagorasGoal/src/live/journal.py
T
Adriano Dal Pastro 2751a7efd0 journal: la voce del giorno IN CORSO non si presenta piu' come una giornata
cron_daily (00:30 UTC) faceva DUE chiamate al giornale: chiudeva IERI (giusto)
e apriva OGGI. La pagina di oggi nasceva con ~30 minuti dentro e nessuno la
aggiornava fino alla notte dopo.

Il difetto non era il dato mancante: era che la pagina non lo diceva dove si
legge. Aveva TUTTE le sezioni di una chiusa -> passava ogni controllo di
completezza e freschezza, e la parzialita' stava solo in §Salute, quinta
sezione su otto. Stessa famiglia di "una barra presente non e' una giornata
presente", in veste nuova: la pagina e' presente, il giorno no.

Taglia misurata sul caso reale di oggi: la pagina congelata alle 00:37 diceva
"giorno: $+0.54 (1 letture)" e la regola [pnl] chiosava "senza operare". Alle
08:54 il libro aveva fatto 3 fill, 2 round-trip e +$25.86 ($642.56 -> $667.88).
Avrebbe raccontato una giornata ferma per tutta una giornata operativa.

Riparato in due pezzi indipendenti:
  1. la pagina dichiara la parzialita' nel TITOLO e nella prima riga —
     "PARZIALE (giorno in corso)", copertura esplicita (N giri su 24), il fatto
     che non si aggiorna da sola, e quali numeri sono di quella frazione;
  2. tolta la seconda chiamata dal cron. In docs/journal/ restano solo giorni
     chiusi; lo stato corrente si prende da journal.py a mano (pagina marcata)
     o da trades.db, che cron_book sincronizza ogni ora.

Scelta dichiarata: NON si rigenera ogni ora. Costerebbe una riga in cron_book
ma riscriverebbe un file tracciato da git 24 volte al giorno per un consumatore
che non esiste (l'analista legge il giorno chiuso). Se un domani servisse, la
strada e' RIGENERARLA, non congelarla: e' scritto nel commento del cron.

Prove (test_journal 28 -> 31): una accende la marcatura, una la tiene spenta su
un giorno chiuso (una marcatura sempre accesa non si legge), una e' guardia sul
SORGENTE di cron_daily.sh — ogni chiamata a journal.py deve avere --giorno
esplicito, perche' senza argomenti scrive OGGI.

Regolare docs/journal/2026-08-25.md rigenerato: ora si dichiara PARZIALE (9/24).

NON riparato, e registrato come debito aperto in CLAUDE.md §5:
test_wave_0726::test_t1_canonical_riproduce_il_backtest_ufficiale fallisce, e
falliva gia' prima di questa modifica (verificato con git stash sull'albero
pulito). Sharpe hold-out SKH01 canonical 1,9223 contro la banda cablata
1.3 < h < 1.9: il codice non e' cambiato, sono cambiati i dati (data/raw e'
gitignored, il cron lo ricostruisce ogni notte). La banda NON e' stata
allargata — lezione 07/08. Suite: 710 passati, 1 fallito.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:59:42 +00:00

466 lines
25 KiB
Python

"""Giornale di bordo del libro live: una voce al giorno, MISURATA.
REGOLA DI QUESTO MODULO: il giornale registra NUMERI e stati derivati dai numeri.
Non scrive narrativa di mercato, non interpreta, non prevede. Il campo `nota` esiste
apposta per il testo libero dell'operatore, ed e' l'unico posto dove puo' finire un'opinione:
resta vuoto se nessuno lo scrive. *Un giornale che si inventa la lettura del mercato smette
di essere una misura e diventa un racconto — e fra sei mesi non si distingue piu' quale delle
due cose si stava leggendo.*
TRE STATI, mai due: ogni grandezza e' un numero, oppure `None` con una ragione. Un dato
mancante NON diventa zero (lezione `paper_dvolspread` / `venue_watch`: "non vedo" non e'
"va tutto bene").
"""
from __future__ import annotations
import json
import math
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.live import tradesdb as T
PROJECT_ROOT = Path(__file__).resolve().parents[2]
JOURNAL_DIR = PROJECT_ROOT / "docs" / "journal"
ASSETS = ("BTC", "ETH")
# Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni).
ORIZZONTI = (30, 90, 180)
def _giornaliero(df: pd.DataFrame) -> pd.Series:
s = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))["close"]
return s.resample("1D").last().dropna()
def _rv_annua(px: pd.Series, n: int = 30) -> float | None:
r = np.log(px).diff().dropna()
if len(r) < n:
return None
return float(r.tail(n).std(ddof=1) * math.sqrt(365) * 100)
def metriche_mercato(giorno: date) -> dict:
"""Stato del mercato al `giorno`, dal feed CERTIFICATO. Nessuna chiamata di rete."""
from src.data.downloader import load_data
out: dict = {}
for a in ASSETS:
try:
px = _giornaliero(load_data(a, "1h"))
except Exception as e:
out[a] = dict(errore=f"feed non leggibile: {type(e).__name__}")
continue
px = px[px.index.date <= giorno]
if px.empty:
out[a] = dict(errore="nessuna barra fino al giorno richiesto")
continue
ult = float(px.iloc[-1])
d = dict(chiusura=ult, barra=str(px.index[-1].date()))
for k, n in (("ret_1g", 1), ("ret_7g", 7), ("ret_30g", 30)):
d[k] = float(px.iloc[-1] / px.iloc[-1 - n] - 1) * 100 if len(px) > n else None
d["rv30_annua"] = _rv_annua(px)
# gli stessi orizzonti del segnale di produzione: e' lo stato che il libro LEGGE
d["tsmom"] = {f"{n}g": (None if len(px) <= n else int(np.sign(px.iloc[-1] / px.iloc[-1 - n] - 1)))
for n in ORIZZONTI}
segni = [v for v in d["tsmom"].values() if v is not None]
d["tsmom_su"] = (sum(1 for v in segni if v > 0), len(segni)) if segni else None
# DVOL: se il file non c'e' o e' vecchio -> None con ragione, mai 0
f = PROJECT_ROOT / "data" / "raw" / f"dvol_{a.lower()}.parquet"
if f.exists():
dv = pd.read_parquet(f)
dv.index = pd.to_datetime(dv["timestamp"], unit="ms", utc=True)
dv = dv[dv.index.date <= giorno]["close"]
if len(dv):
eta = (giorno - dv.index[-1].date()).days
d["dvol"] = float(dv.iloc[-1]) if eta <= 2 else None
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:
d["dvol"] = None; d["dvol_nota"] = "file DVOL assente"
out[a] = d
return out
def stato_libro(con, giorno: date) -> dict:
"""Ultimo giro di `book_execute` del giorno: cosa il libro vedeva e teneva."""
runs = T.parse_cron_log(T.CRON_LOG.read_text(errors="replace")) if T.CRON_LOG.exists() else []
g = [r for r in runs if r.ts_utc[:10] == giorno.isoformat()]
if not g:
return dict(errore="nessun giro di book_execute quel giorno", giri=0)
ult = g[-1]
lordo = sum(abs(v.get("pos", 0.0)) for v in ult.stato_asset.values())
return dict(giri=len(g), ultimo_giro=ult.ts_utc, equity=ult.equity, barra=ult.last_bar,
feed_skh_min=ult.feed_min, nozionale_lordo=lordo,
leva_lorda=(lordo / ult.equity if ult.equity else None),
asset={a: dict(tp_frac=v["tp_frac"], skh_sign=v["skh_sign"],
skh_entry=v.get("skh_entry"), target=v["net"], posizione=v["pos"],
azione=v["azione"]) for a, v in ult.stato_asset.items()})
def pnl_giorno(con, giorno: date) -> dict:
"""P&L del giorno. `equity` e' l'autorita' (venue); il resto e' scomposizione locale."""
g = giorno.isoformat()
eq = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc LIKE ? ORDER BY ts_utc",
(f"{g}%",)).fetchall()
prima = con.execute("SELECT equity FROM equity WHERE ts_utc < ? ORDER BY ts_utc DESC LIMIT 1",
(g,)).fetchone()
e_ini = prima["equity"] if prima else (eq[0]["equity"] if eq else None)
e_fin = eq[-1]["equity"] if eq else None
rt = con.execute("SELECT * FROM roundtrips WHERE ts_out LIKE ?", (f"{g}%",)).fetchall()
fills = con.execute("SELECT * FROM fills WHERE ts_utc LIKE ?", (f"{g}%",)).fetchall()
e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone()
return dict(
equity_inizio=e_ini, equity_fine=e_fin,
delta_equity=(e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None,
realizzato_lordo=sum(r["pnl_lordo"] for r in rt) if rt else 0.0,
realizzato_netto=sum(r["pnl_netto"] for r in rt) if rt else 0.0,
roundtrip_chiusi=len(rt), fill=len(fills),
fee=sum(f["fee"] for f in fills) if fills else 0.0,
cumulato_da_arming=(e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None,
letture_equity=len(eq))
def salute(con, giorno: date) -> dict:
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un
allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero.
"""
lb = stato_libro(con, giorno)
adesso = datetime.now(timezone.utc)
in_corso = giorno == adesso.date()
atteso = (adesso.hour + 1) if in_corso else 24
fatti = lb.get("giri", 0)
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti))
def costruisci(con, giorno: date) -> dict:
return dict(giorno=giorno.isoformat(), ts_scritto=T.ora(),
mercato=metriche_mercato(giorno), libro=stato_libro(con, giorno),
pnl=pnl_giorno(con, giorno), salute=salute(con, giorno))
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 "")
con.execute("""INSERT INTO journal (giorno, ts_scritto, mercato, libro, pnl, salute, nota)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(giorno) DO UPDATE SET
ts_scritto=excluded.ts_scritto, mercato=excluded.mercato,
libro=excluded.libro, pnl=excluded.pnl, salute=excluded.salute,
nota=excluded.nota""",
(voce["giorno"], voce["ts_scritto"], json.dumps(voce["mercato"]),
json.dumps(voce["libro"]), json.dumps(voce["pnl"]),
json.dumps(voce["salute"]), nota_finale))
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)
def rendi_markdown(con, voce: dict) -> str:
g, m, lb, p, s = voce["giorno"], voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
riga = con.execute("SELECT * FROM journal WHERE giorno=?", (g,)).fetchone()
nota = (riga["nota"] if riga else "") or ""
an = dict(riga) if riga else {}
# Un giorno IN CORSO ha tutte le sezioni di uno chiuso -> passa ogni controllo di
# completezza e si legge come una giornata intera. La parzialita' va dichiarata nel TITOLO
# e nella prima riga, non nella quinta sezione su otto: chi apre la pagina — o un modello
# che se la ritrova nel prompt — legge quelle due. Stessa famiglia di "una barra presente
# non e' una giornata presente": qui la pagina e' presente, il giorno no.
if s.get("giorno_in_corso"):
testata = [
f"# Giornale di bordo — {g} · ⚠️ PARZIALE (giorno in corso)", "",
f"*Scritto {voce['ts_scritto']} — la giornata NON e' chiusa: la pagina copre "
f"{s.get('giri_book', 0)} giri su 24 e **non si aggiorna da sola**. I numeri di "
f"GIORNO (P&L del giorno, letture, giri) sono di questa frazione, non del giorno; "
f"i CUMULATI sono corretti. La versione completa la scrive il cron dopo le 00:30 "
f"UTC del giorno seguente.*"]
else:
testata = [
f"# Giornale di bordo — {g}", "",
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*"]
L = testata + ["",
"## Mercato", "",
"| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |",
"|---|---|---|---|---|---|---|---|"]
for a in ASSETS:
d = m.get(a) or {}
# tre stati: misurato / errore dichiarato / blocco assente. Nessuno dei tre e' uno zero,
# e l'ultimo non deve far cadere la pagina (un giornale che non si scrive non e' un giornale).
if "errore" in d or "chiusura" not in d:
perche = d.get("errore", "blocco mercato assente")
L.append(f"| **{a}** | n/d | n/d | n/d | n/d | n/d | n/d | {perche} |"); continue
ts = d.get("tsmom", {})
segni = " ".join("↑" if ts.get(f"{n}g") == 1 else "↓" if ts.get(f"{n}g") == -1 else "·"
for n in ORIZZONTI)
su = d.get("tsmom_su")
dv = f"{d['dvol']:.1f}" if d.get("dvol") is not None else f"n/d ({d.get('dvol_nota','vecchio')})"
if d.get("dvol_rank_1a") is not None:
dv += f" ({100*d['dvol_rank_1a']:.0f}° pctl 1a)"
L.append(f"| **{a}** | ${d['chiusura']:,.2f} | {_n(d.get('ret_1g'),'{:+.2f}%')} | "
f"{_n(d.get('ret_7g'),'{:+.2f}%')} | {_n(d.get('ret_30g'),'{:+.2f}%')} | "
f"{_n(d.get('rv30_annua'),'{:.1f}%')} | {segni}"
f"{f' ({su[0]}/{su[1]} su)' if su else ''} | {dv} |")
L += ["", "## Libro", ""]
if "errore" in lb:
L.append(f"⚠️ {lb['errore']}")
else:
L += [f"Equity **${lb['equity']:,.2f}** · nozionale lordo ${lb['nozionale_lordo']:,.0f} "
f"· leva lorda {_n(lb.get('leva_lorda'),'{:.2f}x')} · barra dati {lb['barra']} "
f{lb['giri']} giri", "",
"| | TP01 | SKH01 | target | posizione | azione |", "|---|---|---|---|---|---|"]
for a, v in sorted(lb["asset"].items()):
if v["skh_sign"] == 0:
sk = "flat"
else:
sk = "LONG" if v["skh_sign"] > 0 else "SHORT"
if v.get("skh_entry"):
sk += " @ {:,.1f}".format(v["skh_entry"])
L.append(f"| **{a}** | {v['tp_frac']:+.3f} | {sk} | ${v['target']:+,.0f} | "
f"${v['posizione']:+,.0f} | {v['azione']} |")
L += ["", "## P&L", "",
f"- giorno: **{_n(p.get('delta_equity'),'${:+,.2f}')}** di equity "
f"({p['letture_equity']} letture)",
f"- realizzato: {p['realizzato_netto']:+.2f} netto su {p['roundtrip_chiusi']} round-trip "
f"chiusi · {p['fill']} fill · fee {p['fee']:.4f}",
f"- cumulato dall'arming: **{_n(p.get('cumulato_da_arming'),'${:+,.2f}')}**", "",
"## Salute", "",
f"- giri di `book_execute`: {s['giri_book']}/{s['atteso_giri']}"
+ (" *(giorno in corso)*" if s.get("giorno_in_corso") else "")
+ (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"),
""]
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)*"]
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)
def scrivi_file(con, voce: dict) -> Path:
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
f = JOURNAL_DIR / f"{voce['giorno']}.md"
f.write_text(rendi_markdown(con, voce))
return f