libro di bordo: DB dei trade allineato col tempo + giornale giornaliero

I trade erano salvati, ma non allineati col tempo: book_execute.py scriveva
ts_utc = pd.Timestamp(r['last_data']), cioe' la data della BARRA DI SEGNALE.
19 righe su 19 a 00:00:00, e un trade (ETH 0.04 @ 1869.74) registrato SEI
GIORNI prima di essere eseguito — fill vero 2026-07-14T14:00, scritto 08/07.
L'ora vera esisteva solo in logs/cron_book.log, che e' gitignored, fuori dal
backup e ruotabile: la cronologia reale del libro live viveva in un file che
una rotazione avrebbe cancellato senza che nessuno se ne accorgesse.

- src/live/tradesdb.py: parser del cron log (ora vera + contesto del segnale),
  FIFO con fee pro-quota, riconciliazione a tre fonti, sqlite in data/live/
  (dentro il perimetro del backup). Le tre fonti si INCROCIANO e non si
  sovrascrivono: reconcile() riporta le divergenze e non ripara niente da solo.
  Il venue e' autorevole ma TRONCA (1 trade su BTC, 0 su ETH): dichiarato.
- scripts/live/trades_db.py: --sync (idempotente, in cron_book ogni ora),
  --report, --reconcile.
- src/live/journal.py + scripts/live/journal.py: una voce al giorno in
  docs/journal/YYYY-MM-DD.md — mercato (ritorni, RV30, TSMOM sugli orizzonti
  di produzione, DVOL con eta'), libro (TP01/SKH01, target, posizione, leva),
  P&L (equity del venue come autorita', scomposizione locale), salute.
  NIENTE narrativa automatica: il campo `nota` e' l'unico posto per il testo
  libero ed e' dell'operatore, mai riscritto da un ricalcolo.
- book_execute.py: ts_utc = ora vera del fill, bar_ts = barra. Test di
  regressione sulla sorgente: se qualcuno rimette last_data, il test lo dice.
- 62 voci di giornale ricostruite dall'arming a oggi.

Tre difetti trovati dai test mentre scrivevo, non a occhio: il renderer cadeva
in KeyError se mancava il blocco mercato (un giornale che non si scrive non e'
un giornale); "24 giri attesi" su un giorno IN CORSO produceva un allarme a
ogni esecuzione; e libro e P&L leggevano due istanti diversi, quindi la stessa
pagina mostrava due equity.

Strategia, pesi, config INVARIATI. Nessun ordine. 659 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:13:02 +00:00
parent 0cd0747e95
commit 10c373075c
72 changed files with 3228 additions and 1 deletions
+233
View File
@@ -0,0 +1,233 @@
"""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())
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:
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()
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"]
nota = con.execute("SELECT nota FROM journal WHERE giorno=?", (g,)).fetchone()
nota = nota["nota"] if nota else ""
L = [f"# Giornale di bordo — {g}", "",
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*", "",
"## 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"),
"", "## 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