"""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