"""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" # Movimenti di capitale DICHIARATI dall'operatore (append-only, dentro il perimetro di backup — # P11). Servono per i movimenti che il rilevatore non puo' vedere: sotto `EQUITY_JUMP_ALERT` # (il versamento di prova da ~$25 del 2026-08-25 08:00Z, +3,3% su $647, restato nel P&L per # 12 giorni) o dentro un'ora di mercato non misurabile. Una riga per movimento: # {"ts_utc": "...", "delta_usd": 24.9, "banda_usd": 0.5, "nota": "...", "dichiarato_il": "..."} MOVIMENTI_DICHIARATI = PROJECT_ROOT / "data" / "live" / "movimenti_dichiarati.jsonl" 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 _px_1h(asset: str) -> pd.Series | None: """Chiusure orarie UTC dal feed certificato; None se non leggibile (P5: si dichiara).""" try: from src.data.downloader import load_data df = load_data(asset, "1h") s = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))["close"] return s.sort_index() except Exception: return None def movimenti_dichiarati(path: Path | None = None) -> tuple[list[dict], list[str]]: """Righe di `MOVIMENTI_DICHIARATI` -> (movimenti validi, errori di riga). Mai solleva. Una riga rotta non azzera le altre e NON sparisce: torna in `errori` (P3 — si registra nel punto in cui si ingoia).""" path = MOVIMENTI_DICHIARATI if path is None else path if not path.exists(): return [], [] validi, errori = [], [] for n, riga in enumerate(path.read_text().splitlines(), 1): if not riga.strip(): continue try: d = json.loads(riga) ts = pd.Timestamp(d["ts_utc"]) if ts.tzinfo is None: raise ValueError("ts_utc senza fuso") delta = float(d["delta_usd"]) if delta == 0: raise ValueError("delta_usd zero") validi.append(dict(ts=ts, delta=delta, banda=float(d.get("banda_usd") or 0.0), nota=str(d.get("nota") or ""), dichiarato_il=d.get("dichiarato_il"))) except Exception as e: # noqa: BLE001 — la riga rotta si riporta errori.append(f"{path.name}:{n}: {type(e).__name__}: {e}") validi.sort(key=lambda d: d["ts"]) return validi, errori def movimenti_capitale(con, fino_ts: str, rows=None) -> dict: """Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L. PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce dentro come se fosse profitto: la voce del 2026-08-25 dichiarava «giorno: +$1.414,57» quando $1.399 erano un deposito USDC, e il «cumulato dall'arming» avrebbe mentito per sempre. COME CLASSIFICA (tre stati, mai due): - candidato = salto fra due letture consecutive oltre `EQUITY_JUMP_ALERT` del book — la soglia e' IMPORTATA, non ridichiarata (P1: il rilevatore di movimenti e' quello); - `movimento` solo se il salto supera di >2x il massimo che il MERCATO avrebbe potuto produrre nell'intervallo: |Δprezzo| reale dal feed certificato x il tetto di leva letto da config. Non un'ipotesi sul mercato: il mercato misurato in quella finestra; - `ambiguo` altrimenti, o quando il mercato non e' misurabile: si DICHIARA e non si scorpora (P12 — una riparazione silenziosa e' un'invenzione; un crash vero a tutta leva non deve trasformarsi in "prelievo"). ⚠️ LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000) non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live. Il rimedio NON e' abbassare la soglia (ogni ora di mercato diventerebbe un candidato): e' la DICHIARAZIONE dell'operatore in `MOVIMENTI_DICHIARATI` — l'unica fonte che sa l'importo. Un movimento dichiarato e' `movimento` con `fonte="dichiarato"`, l'importo e' quello dichiarato (non il salto di equity), e `dopo` = `prima` + importo: il P&L di mercato di quell'ora resta nel rendimento invece di uscire con il movimento. Se coincide con un salto rilevato, l'importo dichiarato VINCE sul salto (fonte "dichiarato+rilevato"); un movimento dichiarato fuori dalle letture, o dopo `fino_ts`, non entra e finisce in `avvisi`. """ from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata # `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron # possono cadere ai due lati di una scrittura e descrivere due istanti diversi. if rows is None: rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc", (fino_ts,)).fetchall() soglia = float(_book.EQUITY_JUMP_ALERT) candidati = [(a, b) for a, b in zip(rows, rows[1:]) if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia] dichiarati, avvisi = movimenti_dichiarati() if not candidati and not dichiarati: return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi) try: cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) # la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il # tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del # 01/09 entra qui da sola) scala = float(cfg.get("book_scale_k") or 1.0) leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala, float(_book.LEVA_LORDA_MAX)) except Exception: leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice px = {a: _px_1h(a) for a in ASSETS} if candidati else {} eventi = [] for prev, cur in candidati: t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"]) delta = float(cur["equity"] - prev["equity"]) pct = delta / prev["equity"] # Il mercato e' MISURABILE solo se il feed copre entrambe le letture. Il feed 1h # certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno # `asof` restituirebbe la STESSA barra per t0 e t1, max_mkt 0, e qualunque calo >=10% # diventerebbe un "movimento" — un crash stampato come prelievo (revisione 02/09). # Non misurabile => `ambiguo` con la ragione (P5: "non vedo" non e' "mercato fermo"). mosse, non_mis = [], [] for a in ASSETS: s = px.get(a) if s is None or s.empty: non_mis.append(f"{a}: feed 1h non leggibile"); continue fine = s.index[-1] + pd.Timedelta(hours=1) # l'ultima barra copre un'ora if t0 < s.index[0] or t1 > fine: non_mis.append(f"{a}: feed 1h fermo a {s.index[-1]:%Y-%m-%dT%H:%M}Z"); continue p0, p1 = s.asof(t0), s.asof(t1) if pd.isna(p0) or pd.isna(p1) or not p0 or not p1: non_mis.append(f"{a}: barra assente"); continue mosse.append(abs(float(p1) / float(p0) - 1.0)) if non_mis: max_mkt, classe, nota = None, "ambiguo", "mercato non misurabile: " + "; ".join(non_mis) else: max_mkt = max(mosse) * leva_tetto classe = "movimento" if abs(pct) > 2.0 * max_mkt else "ambiguo" nota = None eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"], prima=float(prev["equity"]), dopo=float(cur["equity"]), delta=delta, pct=float(pct), mercato_max_pct=(None if max_mkt is None else float(max_mkt)), mercato_nota=nota, classe=classe, fonte="rilevato")) # --- dichiarati dall'operatore: l'importo lo sa solo lui ------------------------------ ts_rows = [pd.Timestamp(r["ts_utc"]) for r in rows] for d in dichiarati: if not rows or d["ts"] <= ts_rows[0] or d["ts"] > ts_rows[-1]: avvisi.append(f"movimento dichiarato {d['ts']:%Y-%m-%dT%H:%M}Z ${d['delta']:+,.2f} " f"fuori dalle letture [{rows[0]['ts_utc'][:16] if rows else '-'} .. " f"{rows[-1]['ts_utc'][:16] if rows else '-'}]: non applicato") continue i = next(k for k in range(1, len(rows)) if ts_rows[k - 1] < d["ts"] <= ts_rows[k]) prev, cur = rows[i - 1], rows[i] nota_d = (f"dichiarato dall'operatore" + (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "") + (f": {d['nota']}" if d["nota"] else "") + (f" (banda ±${d['banda']:,.2f})" if d["banda"] else "")) gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None) if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None, classe="movimento", fonte="dichiarato+rilevato", dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d) continue eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"], prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"], delta=d["delta"], pct=(d["delta"] / float(prev["equity"]) if prev["equity"] else None), mercato_max_pct=None, mercato_nota=None, classe="movimento", fonte="dichiarato", dichiarazione=nota_d)) eventi.sort(key=lambda e: e["ts_dopo"]) return dict(eventi=eventi, certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")), ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")), soglia=soglia, leva_tetto=leva_tetto, avvisi=avvisi) def rendimento_twr(con, fino_ts: str) -> dict: """Rendimento pesato per il tempo (TWR) della serie di equity, spezzata sui movimenti CERTI. PERCHE' ESISTE (debito #14, trovato il 2026-09-01). `e1/e0 - 1` sulla serie grezza era il numero che `trades_db.py --report` stampava come performance: «+243%», per il 96,3% un bonifico ($1.399 il 25/08 su $667). La riparazione — `movimenti_capitale` — viveva gia' in questo modulo e NON aveva attraversato il confine fra i due lettori della stessa serie: variante di P1. Da oggi il secondo lettore CHIAMA il primo invece di rifare il conto. COME. Ogni evento di classe `movimento` chiude un segmento all'equity PRIMA del salto e ne apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto). ⚠️ LIMITE DICHIARATO (D5). L'intervallo che CONTIENE un movimento certo esce INTERO dal rendimento: il suo P&L di mercato (marcatura delle posizioni aperte, funding; in un buco di lettura — fino a 10h nella serie vera — l'intero buco) finisce in `certi`, quindi anche fuori da `trading`. E' limitato per costruzione dal margine del classificatore (|salto| > 2·max_mkt·leva·equity), percio' l'errore massimo vale max_mkt·leva·equity: fino a META' del movimento riconosciuto. Il 25/08: libro a ~$263 lordi, mercato a ±0,2% -> ~$0,5 su $1.399,39. Non si stima e non si corregge (P12): si dichiara. Un intervallo di sola marcatura fra due movimenti consecutivi, o prima del primo, ha lunghezza zero e NON produce un segmento. TRE STATI: `twr` e `trading` sono numeri, oppure None con `motivo`. Mercato NON misurabile per almeno un salto (feed 1h fermo, o assente) => entrambi None: non si conosce la classe del salto, quindi non si conosce il rendimento — e `e1/e0-1` non torna sotto un'etichetta piu' autorevole. Base di equity zero => entrambi None: il salto 0 -> X del primo versamento e' INVISIBILE a `movimenti_capitale` (una base zero non ha percentuale), quindi `certi` lo perderebbe e `trading` varrebbe l'intero conto — un numero etichettato "al netto dei versamenti" che li contiene tutti. """ rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc", (fino_ts,)).fetchall() if not rows: return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[], certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0) mv = movimenti_capitale(con, fino_ts, rows=rows) # stessa lettura, un solo istante e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"]) if e0 <= 0: return dict(twr=None, trading=None, motivo="base di equity nulla: il primo versamento non e' classificabile", segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0, letture=len(rows)) non_mis = [e for e in mv["eventi"] if e.get("mercato_nota") and e["classe"] != "movimento"] if non_mis: # un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un # movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe # sotto l'etichetta "TWR", il numero che il debito 14 e' nato per uccidere) return dict(twr=None, trading=None, motivo=(f"{len(non_mis)} salto/i non classificabile/i — " + non_mis[0]["mercato_nota"]), segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0, letture=len(rows)) certi = [e for e in mv["eventi"] if e["classe"] == "movimento"] segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0 for ev in certi: # gia' in ordine di tempo if da_eq and ev["ts_prima"] != da_ts: # lunghezza zero = niente segmento segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"], ret=ev["prima"] / da_eq - 1.0)) da_ts, da_eq = ev["ts_dopo"], ev["dopo"] if da_eq and rows[-1]["ts_utc"] != da_ts: segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1, ret=e1 / da_eq - 1.0)) twr = 1.0 for sg in segmenti: # nessun segmento = nessun tempo a mercato = 0 twr *= 1.0 + sg["ret"] return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0, trading=e1 - e0 - mv["certi"], letture=len(rows), avvisi=mv.get("avvisi") or []) def pnl_giorno(con, giorno: date) -> dict: """P&L del giorno. `equity` e' l'autorita' (venue); il resto e' scomposizione locale. `delta_equity` e `cumulato_da_arming` restano FATTI (l'equity si e' mossa cosi'); `trading_giorno` e `trading_da_arming` sono gli stessi numeri al netto dei movimenti di capitale CERTI — gli ambigui restano dentro, dichiarati a parte.""" 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() delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None # il cumulato dall'arming, i movimenti e il trading al netto vengono da rendimento_twr: # UNA funzione per i due lettori (report e giornale), non due aritmetiche (revisione 02/09) r_twr = rendimento_twr(con, f"{g}T23:59:59+00:00") mv = dict(eventi=r_twr["eventi"], certi=r_twr["certi"], ambigui=r_twr["ambigui"]) ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g] mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento") amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo") return dict( equity_inizio=e_ini, equity_fine=e_fin, delta_equity=delta, 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=cum, letture_equity=len(eq), movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g), trading_giorno=(delta - mov_g) if delta is not None else None, movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"], trading_da_arming=(r_twr["trading"] if cum is not None else None), twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"]) 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. # ============================================================================================= # DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE # ============================================================================================= # N9: quando una decisione viene rinviata con un motivo, il motivo va registrato — altrimenti fra # tre mesi la stessa analisi si ripresenta come se fosse nuova. E una decisione parcheggiata su un # numero che NESSUNO sorveglia e' parcheggiata per sempre: qui la sorveglianza e' l'equity, che il # giornale legge comunque ogni giorno. # # Una riga si TOGLIE quando la decisione e' presa. Finche' c'e', la voce del giorno la ripete: e' # voluto — non e' un allarme che chiede un'azione impossibile (P14), e' una domanda che da quel # giorno in poi si puo' finalmente rispondere. SOGLIE_CAPITALE: tuple[tuple[float, str], ...] = ( (15_000.0, "**GTAA01: tenere o bloccare.** Decisione rinviata a questa soglia il 2026-08-28 " "(diario `2026-08-28-gtaa-fase-e-allarmi`). Sotto $15k di book lo sleeve NON e' " "deployabile — `GTAA_MIN_CAPITAL` $3.000 allocati al peso 20% — quindi finora e' " "stato manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio " "+0,095/+0,124 di Sharpe secondo la fase: reale, sempre positivo, e delle stesse " "dimensioni dello spread fra le baseline."), (20_000.0, "**«100% Deribit fino a $20k»** (decisione del 26/07): la soglia e' arrivata, e con " "essa la domanda del secondo conto (N4: il rischio di venue si compra con un CONTO, " "non con uno sleeve)."), ) def soglie_superate(equity: float | None) -> list[tuple[float, str]]: """PURA. Le decisioni parcheggiate che a questa equity si possono finalmente prendere.""" if equity is None: return [] return [(s, t) for s, t in SOGLIE_CAPITALE if equity >= s] 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)) # --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ---------------------- for e in p.get("movimenti_giorno") or []: h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16] if e["classe"] == "movimento" and e.get("fonte", "rilevato") != "rilevato": dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le " f"{h1} UTC, {e.get('dichiarazione') or 'dichiarato'}. " "Scorporato dal P&L di trading qui sotto.", "nota") elif e["classe"] == "movimento": dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le " f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile " f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. " "Scorporato dal P&L di trading qui sotto.", "nota") else: perche = ("il mercato non e' misurabile in quella finestra" if e["mercato_max_pct"] is None else f"il mercato avrebbe potuto produrlo (max " f"±{100*e['mercato_max_pct']:.1f}% al tetto di leva)") dire("movimento_ambiguo", f"Salto di equity **${e['delta']:+,.2f}** fra le {h0} e le " f"{h1} UTC che NON si puo' attribuire: {perche}. " "Resta nel P&L, dichiarato — non scorporato.", "attenzione") # --- cosa il libro STA FACENDO ----------------------------------------------------------- # (le regole sui movimenti stanno SOPRA questo return: un versamento in un giorno senza # giri di book esiste lo stesso — viene dal DB equity, non dal log del libro) 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) dq_t = p.get("trading_giorno") dq_eff = dq_t if dq_t is not None else dq scorporo = bool(p.get("mov_giorno")) if dq_eff is not None: if p["fill"] and fee > 0 and abs(dq_eff) < fee: dire("fee", f"Il movimento di equity del giorno (${dq_eff:+.2f}" f"{', al netto dei movimenti di capitale' if scorporo else ''}) e' " f"**piu' piccolo delle fee pagate** (${fee:.4f}): la giornata l'ha " "decisa il costo, non il segnale.", "nota") elif p["fill"] == 0: dire("pnl", f"Equity ${dq_eff:+.2f} **senza operare**: e' mark-to-market sulle " "posizioni gia' aperte." + (" (Al netto dei movimenti di capitale.)" if scorporo else "")) else: dire("pnl", (f"P&L di trading ${dq_eff:+.2f} (equity ${dq:+.2f} al lordo del " f"movimento di capitale) " if scorporo else f"Equity ${dq_eff:+.2f} ") + f"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"] # il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento # diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto) mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"] g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() tot = e1 - e0 - sum(e["delta"] for e in mv_ev) picco = max(r["equity"] for r in eq) g7 = [r for r in eq if r["ts_utc"][:10] >= g7s] mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s) # 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"] - mv7) / 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") # --- decisioni che questa equity sblocca ------------------------------------------------- for soglia, testo in soglie_superate(p.get("equity_fine")): dire("soglia_capitale", f"📌 **Soglia ${soglia:,.0f} superata** — {testo}", "attenzione") 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']} |") riga_g = (f"- giorno: **{_n(p.get('delta_equity'),'${:+,.2f}')}** di equity " f"({p['letture_equity']} letture)") if p.get("mov_giorno"): riga_g += (f" — di cui **${p['mov_giorno']:+,.2f} movimenti di capitale** -> trading " f"**{_n(p.get('trading_giorno'),'${:+,.2f}')}**") if p.get("mov_giorno_ambiguo"): riga_g += f" — ⚠️ salto ambiguo di ${p['mov_giorno_ambiguo']:+,.2f} non attribuito" riga_c = f"- cumulato dall'arming: **{_n(p.get('cumulato_da_arming'),'${:+,.2f}')}** di equity" if p.get("mov_arming"): riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading " f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**") if p.get("twr_da_arming") is not None: riga_c += f" · TWR **{100 * p['twr_da_arming']:+.2f}%**" elif p.get("twr_motivo"): riga_c += f" · TWR n/d ({p['twr_motivo']})" if p.get("mov_arming_ambiguo"): riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti" L += ["", "## P&L", "", riga_g, f"- realizzato: {p['realizzato_netto']:+.2f} netto su {p['roundtrip_chiusi']} round-trip " f"chiusi · {p['fill']} fill · fee {p['fee']:.4f}", riga_c, "", "## 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