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
+138
View File
@@ -0,0 +1,138 @@
"""Il giornale di bordo: tre stati invece di due, e la nota dell'operatore che non si perde.
Il rischio di un giornale automatico non e' sbagliare un numero: e' scrivere `0` dove il dato
non c'era, e leggerlo fra sei mesi come una misura.
"""
from __future__ import annotations
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
# ------------------------------------------------- i giri attesi non sono sempre 24
def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch):
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
oggi = datetime.now(timezone.utc).date()
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
feed_skh_min=0))
s = J.salute(db, oggi)
assert s["giorno_in_corso"] is True
assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1
assert s["giri_mancanti"] == 0
def test_giorno_chiuso_si_giudica_su_24(db, monkeypatch):
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=20, feed_skh_min=0))
s = J.salute(db, date(2026, 8, 21))
assert s["giorno_in_corso"] is False
assert s["atteso_giri"] == 24 and s["giri_mancanti"] == 4
def test_controllo_positivo_un_giorno_davvero_bucato_viene_segnalato(db, monkeypatch):
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=6, feed_skh_min=None))
assert J.salute(db, date(2026, 8, 21))["giri_mancanti"] == 18
# ------------------------------------------------- dato assente != zero
def test_dvol_assente_e_None_con_ragione_mai_zero(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path) # nessun file DVOL qui
(tmp_path / "data" / "raw").mkdir(parents=True)
m = J.metriche_mercato(date(2026, 8, 21))
for a in ("BTC", "ETH"):
if "errore" in m[a]:
continue
assert m[a]["dvol"] is None
assert m[a]["dvol_nota"] == "file DVOL assente" # la ragione, non un valore
def test_giorno_senza_giri_e_un_errore_dichiarato_non_un_libro_vuoto(db, monkeypatch):
monkeypatch.setattr(T, "CRON_LOG", Path("/tmp/non-esiste-affatto.log"))
lb = J.stato_libro(db, date(2026, 8, 21))
assert "errore" in lb and lb["giri"] == 0
assert "equity" not in lb # non inventa uno zero
def test_pnl_senza_letture_di_equity_non_fabbrica_un_delta(db):
p = J.pnl_giorno(db, date(2026, 8, 21))
assert p["delta_equity"] is None and p["equity_fine"] is None
assert p["realizzato_netto"] == 0.0 and p["roundtrip_chiusi"] == 0 # 0 trade e' un fatto
# ------------------------------------------------- la nota dell'operatore
def test_la_nota_sopravvive_a_un_ricalcolo(db):
g = date(2026, 8, 21)
voce = dict(giorno=g.isoformat(), ts_scritto=T.ora(), mercato={}, libro={}, pnl={}, salute={})
J.salva(db, voce, nota="rally post-CPI, non ho toccato niente")
J.salva(db, voce) # ricalcolo automatico
n = db.execute("SELECT nota FROM journal WHERE giorno=?", (g.isoformat(),)).fetchone()["nota"]
assert n == "rally post-CPI, non ho toccato niente"
def test_la_nota_si_puo_sostituire_esplicitamente(db):
g = date(2026, 8, 21)
voce = dict(giorno=g.isoformat(), ts_scritto=T.ora(), mercato={}, libro={}, pnl={}, salute={})
J.salva(db, voce, nota="prima")
J.salva(db, voce, nota="seconda")
n = db.execute("SELECT nota FROM journal WHERE giorno=?", (g.isoformat(),)).fetchone()["nota"]
assert n == "seconda"
# ------------------------------------------------- il markdown non mente
def test_il_markdown_dice_n_d_e_non_zero_quando_manca_il_dato(db):
voce = dict(giorno="2026-08-21", ts_scritto=T.ora(),
mercato={"BTC": {"errore": "feed non leggibile: FileNotFoundError"},
"ETH": {"errore": "feed non leggibile: FileNotFoundError"}},
libro={"errore": "nessun giro di book_execute quel giorno", "giri": 0},
pnl=J.pnl_giorno(db, date(2026, 8, 21)),
salute=dict(giri_book=0, feed_skh_min=None, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=24))
J.salva(db, voce)
md = J.rendi_markdown(db, voce)
assert "n/d" in md and "24 mancanti" in md
assert "non misurata" in md # eta' feed sconosciuta
assert "$+0.00 di equity" not in md # niente zeri inventati
def test_il_markdown_riporta_il_segnale_che_il_libro_leggeva(db):
voce = dict(giorno="2026-08-21", ts_scritto=T.ora(), mercato={},
libro=dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=644.0,
barra="2026-08-21", feed_skh_min=0, nozionale_lordo=196.0,
leva_lorda=0.304,
asset={"BTC": dict(tp_frac=0.176, skh_sign=1, skh_entry=75280.0,
target=123.0, posizione=126.0, azione="HOLD (a target)")}),
pnl=J.pnl_giorno(db, date(2026, 8, 21)),
salute=dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=0))
J.salva(db, voce)
md = J.rendi_markdown(db, voce)
assert "+0.176" in md and "LONG @ 75,280.0" in md and "0.30x" in md
def test_lo_stato_tsmom_usa_gli_orizzonti_di_produzione():
"""Il giornale deve riportare CIO' CHE IL LIBRO LEGGE, non un trend a caso."""
from src.strategies import trend_portfolio as tp
src = Path(tp.__file__).read_text()
for n in J.ORIZZONTI:
assert str(n) in src, f"orizzonte {n} non compare in trend_portfolio.py"
+207
View File
@@ -0,0 +1,207 @@
"""Il libro di bordo dei trade: parsing, FIFO, riconciliazione, idempotenza.
Ogni rilevatore ha il suo CONTROLLO POSITIVO: un parser che non estrae mai niente e uno che
estrae tutto passano gli stessi test pigri, e nessuno dei due sta misurando qualcosa.
"""
from __future__ import annotations
import sqlite3
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402
LOG_FINTO = """===== 2026-08-20T04:07:01Z cron_book =====
modo : ESECUZIONE REALE
conto reale : $610.82
ultima barra : 2026-08-20
feed SKH : fresco (3 min)
BTC TP +0.199 · SKH +1(LONG@66475.9) -> net $+122 | pos $+76 -> BUY $+46
-> BUY 0.0007 @ $69,351.8 fee 0.01699 (OK)
ETH TP +0.294 · SKH +0(flat) -> net $+67 | pos $+0 -> BUY $+67
-> BUY 0.0298 @ $2,257.9 fee 0.02355 (OK)
===== done 04:07:14Z =====
===== 2026-08-20T05:07:01Z cron_book =====
conto reale : $611.90
ultima barra : 2026-08-20
feed SKH : fresco (0 min)
BTC TP +0.199 · SKH +1(LONG@66475.9) -> net $+123 | pos $+123 -> HOLD (a target)
ETH TP +0.294 · SKH +0(flat) -> net $+67 | pos $+67 -> HOLD (a target)
=> Nessuna azione: conto gia' al target netto del book.
===== done 05:07:11Z =====
"""
# ---------------------------------------------------------------- parsing del log
def test_estrae_ora_vera_e_non_la_barra():
runs = T.parse_cron_log(LOG_FINTO)
assert len(runs) == 2
f = runs[0].fills[0]
assert f.ts_utc.startswith("2026-08-20T04:07:01") # ora del giro
assert f.bar_ts == "2026-08-20" # barra, sotto il suo nome
assert f.ts_utc[:10] != "" and "T" in f.ts_utc # non e' una data nuda
def test_estrae_contesto_del_segnale():
r = T.parse_cron_log(LOG_FINTO)[0]
assert r.equity == 610.82 and r.feed_min == 3
f = [x for x in r.fills if x.asset == "BTC"][0]
assert f.tp_frac == 0.199 and f.skh_sign == 1 and f.skh_entry == 66475.9
assert f.net_target == 122 and f.pos_before == 76
assert f.qty == 0.0007 and f.price == 69351.8 and f.fee == 0.01699
assert f.verified == 1
def test_controllo_positivo_un_giro_senza_fill_non_ne_inventa():
"""Il secondo giro e' un HOLD: se il parser tornasse fill anche li', non starebbe leggendo."""
runs = T.parse_cron_log(LOG_FINTO)
assert len(runs[1].fills) == 0
assert runs[1].stato_asset["BTC"]["azione"] == "HOLD (a target)"
def test_fill_non_verificato_resta_marcato():
log = LOG_FINTO.replace("fee 0.01699 (OK)", "fee 0.01699 (NON VERIFICATO: timeout)")
f = T.parse_cron_log(log)[0].fills[0]
assert f.verified == 0
def test_fill_id_deterministico_e_distingue_due_fill_simili():
a = T.Fill(ts_utc="2026-08-20T04:07:01+00:00", asset="BTC", side="buy", qty=0.0007, price=69351.8, fee=0.01)
b = T.Fill(ts_utc="2026-08-20T04:07:01+00:00", asset="BTC", side="buy", qty=0.0007, price=69351.8, fee=0.01)
c = T.Fill(ts_utc="2026-08-20T05:07:01+00:00", asset="BTC", side="buy", qty=0.0007, price=69351.8, fee=0.01)
assert a.fill_id == b.fill_id and a.fill_id != c.fill_id
# ---------------------------------------------------------------- FIFO
def _f(ts, asset, side, qty, price, fee=0.0):
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee)
def test_fifo_chiude_nell_ordine_giusto_e_alloca_le_fee():
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0, 1.0),
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 200.0, 1.0),
_f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 300.0, 1.0)]
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1
assert rts[0]["px_in"] == 100.0 # FIFO: chiude il PRIMO lotto
assert rts[0]["pnl_lordo"] == pytest.approx(200.0)
assert rts[0]["fee_quota"] == pytest.approx(2.0) # fee d'entrata + d'uscita, pro-quota
assert rts[0]["pnl_netto"] == pytest.approx(198.0)
assert rts[0]["ore_tenuta"] == pytest.approx(48.0)
assert aperti["BTC"][0]["price"] == 200.0 # resta il secondo lotto
def test_fifo_chiusura_parziale_spezza_il_lotto():
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "buy", 1.0, 100.0),
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 0.25, 120.0)]
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1 and rts[0]["qty"] == pytest.approx(0.25)
assert rts[0]["pnl_lordo"] == pytest.approx(5.0)
assert aperti["ETH"][0]["qty"] == pytest.approx(0.75)
def test_fifo_non_mescola_gli_asset():
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
rts, aperti = T.fifo_roundtrips(fills)
assert rts == [] # la vendita ETH non chiude il long BTC
assert set(aperti) == {"BTC"}
def test_fifo_riproduce_il_conto_del_libro_vero():
"""Controllo di realta' sui numeri gia' pubblicati: 12 round-trip, 11 in utile."""
if not T.CRON_LOG.exists():
pytest.skip("cron_book.log assente")
fills = [f for r in T.parse_cron_log(T.CRON_LOG.read_text(errors="replace")) for f in r.fills]
rts, _ = T.fifo_roundtrips(fills)
assert len(rts) >= 12
assert sum(1 for r in rts if r["pnl_netto"] > 0) >= 11
# ---------------------------------------------------------------- riconciliazione
def test_riconciliazione_scopre_lo_sfasamento_di_data():
"""IL difetto vero: il jsonl datava un trade alla BARRA, non al fill (14/07 scritto 08/07)."""
fills = [_f("2026-07-14T14:00:02+00:00", "ETH", "buy", 0.04, 1869.7)]
righe = [dict(ts_utc="2026-07-08 00:00:00", asset="ETH", side="buy", filled=0.04, price=1869.74)]
rec = T.reconcile(fills, righe)
assert len(rec["ok"]) == 0
assert len(rec["solo_log"]) == 1 and len(rec["solo_jsonl"]) == 1
def test_riconciliazione_controllo_positivo_quando_concordano():
"""Speculare del precedente: se le due fonti coincidono NON deve segnalare niente."""
fills = [_f("2026-08-20T04:07:01+00:00", "BTC", "buy", 0.0007, 69351.8)]
righe = [dict(ts_utc="2026-08-20 00:00:00", asset="BTC", side="buy", filled=0.0007, price=69351.8)]
rec = T.reconcile(fills, righe)
assert len(rec["ok"]) == 1 and not rec["solo_log"] and not rec["solo_jsonl"]
assert not rec["prezzi_divergenti"]
def test_riconciliazione_segnala_un_prezzo_davvero_diverso_non_l_arrotondamento():
base = dict(asset="BTC", side="buy", filled=0.0007, ts_utc="2026-08-20 00:00:00")
f = [_f("2026-08-20T04:07:01+00:00", "BTC", "buy", 0.0007, 69351.8)]
assert not T.reconcile(f, [dict(base, price=69351.84)])["prezzi_divergenti"] # stampa a 1 dec.
assert T.reconcile(f, [dict(base, price=69400.00)])["prezzi_divergenti"] # divergenza vera
# ---------------------------------------------------------------- persistenza
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def test_upsert_e_idempotente(db):
fills = [f for r in T.parse_cron_log(LOG_FINTO) for f in r.fills]
assert T.upsert_fills(db, fills) == 2
assert T.upsert_fills(db, fills) == 0 # secondo giro: nessun duplicato
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
def test_roundtrip_sono_derivati_non_accumulati(db):
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
_f("2026-01-02T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)])
assert T.rebuild_roundtrips(db) == 1
assert T.rebuild_roundtrips(db) == 1 # ricalcolo, non append
assert db.execute("SELECT COUNT(*) c FROM roundtrips").fetchone()["c"] == 1
def test_equity_una_riga_per_istante(db):
assert T.upsert_equity(db, [("2026-01-01T00:00:00+00:00", 600.0, "x")]) == 1
assert T.upsert_equity(db, [("2026-01-01T00:00:00+00:00", 999.0, "x")]) == 0
assert db.execute("SELECT equity FROM equity").fetchone()["equity"] == 600.0
def test_stato_aperto_media_i_lotti(db):
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "buy", 1.0, 100.0),
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 200.0)])
st = T.stato_aperto(db)
assert st["ETH"]["qty"] == pytest.approx(2.0)
assert st["ETH"]["prezzo_medio"] == pytest.approx(150.0)
# ---------------------------------------------------------------- regressione sulla SORGENTE
def test_l_esecutore_non_data_piu_i_fill_alla_barra_di_segnale():
"""Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`.
Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte.
"""
src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text()
assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src
assert "bar_ts=str(pd.Timestamp(r['last_data']))" in src
assert "ts_utc=datetime.now(timezone.utc)" in src