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:
@@ -23,6 +23,8 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -210,7 +212,12 @@ def _run():
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Giornale di bordo: scrive la voce del giorno nel DB e in `docs/journal/YYYY-MM-DD.md`.
|
||||
|
||||
uv run python scripts/live/journal.py # voce di oggi
|
||||
uv run python scripts/live/journal.py --giorno 2026-08-21
|
||||
uv run python scripts/live/journal.py --backfill 60 # ricostruisce gli ultimi N giorni
|
||||
uv run python scripts/live/journal.py --nota "testo" # annota il giorno (operatore)
|
||||
|
||||
SOLA LETTURA su feed, log e venue: non tocca il libro, non manda ordini.
|
||||
Il campo `nota` e' l'UNICO posto per il testo libero, e non viene mai riscritto da un
|
||||
ricalcolo automatico.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def _arg(flag: str, default=None):
|
||||
a = sys.argv[1:]
|
||||
return a[a.index(flag) + 1] if flag in a and a.index(flag) + 1 < len(a) else default
|
||||
|
||||
|
||||
def una(con, g: date, nota: str | None = None, verbose: bool = True) -> Path:
|
||||
voce = J.costruisci(con, g)
|
||||
J.salva(con, voce, nota=nota)
|
||||
f = J.scrivi_file(con, voce)
|
||||
if verbose:
|
||||
p = voce["pnl"]
|
||||
d = p.get("delta_equity")
|
||||
print(f" {g} equity {p.get('equity_fine') or float('nan'):,.2f} "
|
||||
f"({'n/d' if d is None else f'{d:+.2f}'}) "
|
||||
f"fill {p['fill']} · rt {p['roundtrip_chiusi']} · giri {voce['salute']['giri_book']}/24"
|
||||
f" -> {f.relative_to(ROOT)}")
|
||||
return f
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# sincronizzare PRIMA: altrimenti la sezione 'Libro' (letta dal log) e la sezione 'P&L'
|
||||
# (letta dal DB) descrivono due istanti diversi e la pagina si contraddice da sola.
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
from trades_db import sync as _sync
|
||||
_sync(verbose=False)
|
||||
|
||||
con = T.connect()
|
||||
nota = _arg("--nota")
|
||||
if (n := _arg("--backfill")):
|
||||
oggi = datetime.now(timezone.utc).date()
|
||||
for i in range(int(n), -1, -1):
|
||||
g = oggi - timedelta(days=i)
|
||||
v = J.costruisci(con, g)
|
||||
# un giorno senza giri di book non e' una voce: e' assenza di dato
|
||||
if v["salute"]["giri_book"] == 0:
|
||||
continue
|
||||
J.salva(con, v); J.scrivi_file(con, v)
|
||||
tot = con.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"]
|
||||
print(f" voci nel giornale: {tot}")
|
||||
else:
|
||||
g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat()))
|
||||
una(con, g, nota=nota, verbose="--quiet" not in sys.argv[1:])
|
||||
con.close()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Sincronizza `data/live/trades.db` — il libro di bordo dei trade, allineato col tempo.
|
||||
|
||||
uv run python scripts/live/trades_db.py --sync # idempotente, da cron
|
||||
uv run python scripts/live/trades_db.py --report # stato + P&L a video
|
||||
uv run python scripts/live/trades_db.py --reconcile # incrocio delle tre fonti
|
||||
|
||||
SOLA LETTURA sul venue e sui log: non manda ordini, non tocca il libro.
|
||||
Il DB sta in `data/live/`, che il backup rotativo della VPS gia' copre — a differenza di
|
||||
`logs/cron_book.log`, che e' oggi l'unica sede dell'ora vera dei fill.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def sync(verbose: bool = True) -> dict:
|
||||
con = T.connect()
|
||||
runs = T.parse_cron_log(T.CRON_LOG.read_text(errors="replace")) if T.CRON_LOG.exists() else []
|
||||
fills = [f for r in runs for f in r.fills]
|
||||
n_f = T.upsert_fills(con, fills)
|
||||
n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None])
|
||||
n_r = T.rebuild_roundtrips(con)
|
||||
if runs:
|
||||
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
|
||||
T.set_meta(con, "ultimo_sync", T.ora())
|
||||
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
||||
tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"]
|
||||
if verbose:
|
||||
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
|
||||
con.close()
|
||||
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot)
|
||||
|
||||
|
||||
def reconcile() -> None:
|
||||
runs = T.parse_cron_log(T.CRON_LOG.read_text(errors="replace"))
|
||||
fills = [f for r in runs for f in r.fills]
|
||||
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||
rec = T.reconcile(fills, righe)
|
||||
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)")
|
||||
print(f" book_executions : {len(righe)} righe (senza ora)")
|
||||
print(f" concordano : {len(rec['ok'])}")
|
||||
print(f" solo nel log : {len(rec['solo_log'])}")
|
||||
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
|
||||
print(f" prezzi divergenti : {len(rec['prezzi_divergenti'])} (oltre l'arrotondamento di stampa)")
|
||||
for f in rec["solo_log"]:
|
||||
print(f" solo-log {f.ts_utc} {f.asset} {f.side} {f.qty} @ {f.price}")
|
||||
for r in rec["solo_jsonl"]:
|
||||
print(f" solo-jsonl {r['ts_utc'][:10]} {r['asset']} {r['side']} {r['filled']} @ {r['price']}")
|
||||
# il venue: autorevole ma TRONCATO -> si riporta cosa vede, non lo si usa per riparare
|
||||
try:
|
||||
from src.live.deribit import DeribitRead
|
||||
d = DeribitRead()
|
||||
for ins in ("BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"):
|
||||
t = d.trade_history(ins, limit=100)
|
||||
print(f" venue {ins:<20}: {len(t)} trade visibili (l'endpoint TRONCA: non e' un backfill)")
|
||||
except Exception as e:
|
||||
print(f" venue: NON LETTO ({type(e).__name__}) — non e' 'zero trade', e' 'non misurato'")
|
||||
|
||||
|
||||
def report() -> None:
|
||||
con = T.connect()
|
||||
n = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
||||
if not n:
|
||||
print(" DB vuoto: lanciare --sync"); return
|
||||
p = con.execute("SELECT MIN(ts_utc) a, MAX(ts_utc) b FROM fills").fetchone()
|
||||
print(f"\n fill registrati : {n} dal {p['a'][:16]} al {p['b'][:16]}")
|
||||
eq = con.execute("SELECT ts_utc, equity FROM equity ORDER BY ts_utc").fetchall()
|
||||
if eq:
|
||||
e0, e1 = eq[0]["equity"], eq[-1]["equity"]
|
||||
picco = max(r["equity"] for r in eq)
|
||||
print(f" equity : ${e0:,.2f} -> ${e1:,.2f} ({e1 - e0:+.2f}, {100*(e1/e0-1):+.2f}%)"
|
||||
f" | picco ${picco:,.2f} | {len(eq)} letture")
|
||||
rt = con.execute("SELECT * FROM roundtrips ORDER BY ts_out").fetchall()
|
||||
lordo = sum(r["pnl_lordo"] for r in rt)
|
||||
fee_rt = sum(r["fee_quota"] for r in rt)
|
||||
fee_tot = con.execute("SELECT COALESCE(SUM(fee),0) s FROM fills").fetchone()["s"]
|
||||
vinc = sum(1 for r in rt if r["pnl_netto"] > 0)
|
||||
print(f" round-trip chiusi : {len(rt)} ({vinc} in utile) "
|
||||
f"lordo {lordo:+.2f} | fee allocate {fee_rt:.2f} | netto {lordo - fee_rt:+.2f}")
|
||||
print(f" fee totali pagate : {fee_tot:.4f}")
|
||||
ap = T.stato_aperto(con)
|
||||
if ap:
|
||||
print(" posizioni aperte :")
|
||||
for a, d in sorted(ap.items()):
|
||||
print(f" {a} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} (dal {d['dal'][:16]})")
|
||||
else:
|
||||
print(" posizioni aperte : nessuna (flat)")
|
||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
if "--reconcile" in args:
|
||||
reconcile()
|
||||
elif "--report" in args:
|
||||
report()
|
||||
else:
|
||||
sync(verbose="--quiet" not in args)
|
||||
Reference in New Issue
Block a user