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:
@@ -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
|
||||
Reference in New Issue
Block a user