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
+351
View File
@@ -0,0 +1,351 @@
"""Libro di bordo del book live: DB dei trade ALLINEATO COL TEMPO + serie di equity.
PERCHE' ESISTE
--------------
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill:
19 righe su 19 a `00:00:00`. L'ora vera sta solo in `logs/cron_book.log` — che e' **gitignored,
non nel backup e ruotabile**. Quindi oggi la cronologia reale del libro live vive in un file
che una rotazione cancella, e nessuno se ne accorgerebbe.
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
il perimetro gia' coperto dal backup rotativo della VPS.
FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola
----------------------------------------------------------------------
1. `logs/cron_book.log` -> ORA VERA del giro + contesto (equity, tp_frac, skh, target, posizione)
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`.
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora.
3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms,
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
Le tre si INCROCIANO, non si sovrascrivono: `reconcile()` riporta le divergenze e non ripara
niente da solo (una riparazione silenziosa fra due fonti che non concordano e' un'invenzione).
TRE STATI, non due: un fill puo' essere `ok` (le fonti concordano), `solo-log`, `solo-jsonl`.
"Non lo vedo" non e' "non c'e'".
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = PROJECT_ROOT / "data" / "live" / "trades.db"
CRON_LOG = PROJECT_ROOT / "logs" / "cron_book.log"
EXEC_JSONL = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
ARMING = "2026-06-20"
SCHEMA = """
CREATE TABLE IF NOT EXISTS fills (
fill_id TEXT PRIMARY KEY,
ts_utc TEXT NOT NULL,
ts_source TEXT NOT NULL,
bar_ts TEXT,
asset TEXT NOT NULL,
side TEXT NOT NULL,
qty REAL NOT NULL,
price REAL NOT NULL,
fee REAL NOT NULL,
action TEXT,
net_target REAL,
pos_before REAL,
pos_after REAL,
tp_frac REAL,
skh_sign INTEGER,
skh_entry REAL,
equity REAL,
order_id TEXT,
verified INTEGER,
stato TEXT NOT NULL DEFAULT 'ok'
);
CREATE INDEX IF NOT EXISTS ix_fills_ts ON fills(ts_utc);
CREATE TABLE IF NOT EXISTS roundtrips (
rt_id INTEGER PRIMARY KEY AUTOINCREMENT,
asset TEXT NOT NULL,
qty REAL NOT NULL,
ts_in TEXT NOT NULL,
px_in REAL NOT NULL,
ts_out TEXT NOT NULL,
px_out REAL NOT NULL,
ore_tenuta REAL,
pnl_lordo REAL NOT NULL,
fee_quota REAL NOT NULL,
pnl_netto REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS equity (
ts_utc TEXT PRIMARY KEY,
equity REAL NOT NULL,
src TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS journal (
giorno TEXT PRIMARY KEY,
ts_scritto TEXT NOT NULL,
mercato TEXT,
libro TEXT,
pnl TEXT,
salute TEXT,
nota TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
"""
# =============================================================================================
# parsing — puro, nessun I/O: prende testo, ritorna dati
# =============================================================================================
@dataclass
class Fill:
ts_utc: str
asset: str
side: str
qty: float
price: float
fee: float
ts_source: str = "cron_log"
bar_ts: str | None = None
action: str | None = None
net_target: float | None = None
pos_before: float | None = None
pos_after: float | None = None
tp_frac: float | None = None
skh_sign: int | None = None
skh_entry: float | None = None
equity: float | None = None
order_id: str | None = None
verified: int = 1
@property
def fill_id(self) -> str:
raw = f"{self.ts_utc}|{self.asset}|{self.side}|{self.qty:.10f}|{self.price:.6f}"
return hashlib.sha1(raw.encode()).hexdigest()[:16]
@dataclass
class Run:
"""Un giro orario di `book_execute`."""
ts_utc: str
equity: float | None = None
last_bar: str | None = None
feed_min: int | None = None
fills: list[Fill] = field(default_factory=list)
stato_asset: dict = field(default_factory=dict)
_RE_HEAD = re.compile(r"^===== (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) cron_book =====$")
_RE_EQ = re.compile(r"conto reale\s*:\s*\$([\d,\.]+)")
_RE_BAR = re.compile(r"ultima barra\s*:\s*(\S+)")
_RE_FEED = re.compile(r"feed SKH\s*:\s*\w+ \((\d+) min\)")
_RE_SIG = re.compile(
r"^\s*(BTC|ETH) TP ([+-][\d\.]+) · SKH ([+-]\d+)\(([^)]*)\) -> net \$([+-][\d,]+) "
r"\| pos \$([+-][\d,]+) -> (.+?)\s*$")
_RE_FILL = re.compile(
r"^\s*-> (BUY|SELL) ([\d\.]+) @ \$([\d,\.]+) fee ([\d\.]+) \((OK|NON VERIFICATO[^)]*)\)")
def _num(s: str) -> float:
return float(s.replace(",", "").replace("$", ""))
def parse_cron_log(text: str) -> list[Run]:
"""Estrae i giri da `cron_book.log`. L'ORA del blocco e' l'ora vera del fill."""
runs: list[Run] = []
cur: Run | None = None
ultimo_asset: str | None = None
for line in text.splitlines():
m = _RE_HEAD.match(line)
if m:
cur = Run(ts_utc=m.group(1).replace("Z", "+00:00"))
runs.append(cur)
ultimo_asset = None
continue
if cur is None:
continue
if (m := _RE_EQ.search(line)) and cur.equity is None:
cur.equity = _num(m.group(1)); continue
if (m := _RE_BAR.search(line)) and cur.last_bar is None:
cur.last_bar = m.group(1); continue
if (m := _RE_FEED.search(line)) and cur.feed_min is None:
cur.feed_min = int(m.group(1)); continue
if (m := _RE_SIG.match(line)):
asset = m.group(1)
entry = None
sk = m.group(4)
if "@" in sk:
try:
entry = float(sk.split("@", 1)[1])
except ValueError:
entry = None
cur.stato_asset[asset] = dict(
tp_frac=float(m.group(2)), skh_sign=int(m.group(3)), skh_entry=entry,
net=_num(m.group(5)), pos=_num(m.group(6)), azione=m.group(7).strip())
ultimo_asset = asset
continue
if (m := _RE_FILL.match(line)) and ultimo_asset:
st = cur.stato_asset.get(ultimo_asset, {})
cur.fills.append(Fill(
ts_utc=cur.ts_utc, asset=ultimo_asset, side=m.group(1).lower(),
qty=float(m.group(2)), price=_num(m.group(3)), fee=float(m.group(4)),
bar_ts=cur.last_bar, action=st.get("azione"), net_target=st.get("net"),
pos_before=st.get("pos"), tp_frac=st.get("tp_frac"),
skh_sign=st.get("skh_sign"), skh_entry=st.get("skh_entry"),
equity=cur.equity, verified=1 if m.group(5) == "OK" else 0))
return runs
def parse_executions_jsonl(text: str) -> list[dict]:
return [json.loads(l) for l in text.splitlines() if l.strip()]
def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0.51) -> dict:
"""Incrocia le due fonti su (data, asset, side, qty). NON ripara: riporta.
`tol_px`: i prezzi del log sono arrotondati a 1 decimale in stampa -> confronto con
tolleranza, e la divergenza sopra tolleranza si REGISTRA invece di essere assorbita.
"""
def chiave(g, a, s, q):
return (g, a, s, round(float(q), 8))
ix_log: dict = {}
for f in fills_log:
ix_log.setdefault(chiave(f.ts_utc[:10], f.asset, f.side, f.qty), []).append(f)
ix_js: dict = {}
for r in righe_jsonl:
ix_js.setdefault(chiave(r["ts_utc"][:10], r["asset"], r["side"], r["filled"]), []).append(r)
ok, solo_log, solo_js, px_diversi = [], [], [], []
for k, gl in ix_log.items():
gj = ix_js.get(k, [])
for i, f in enumerate(gl):
if i < len(gj):
r = gj[i]
if abs(float(r["price"]) - f.price) > tol_px:
px_diversi.append((f, r))
ok.append((f, r))
else:
solo_log.append(f)
for k, gj in ix_js.items():
extra = len(gj) - len(ix_log.get(k, []))
for r in gj[max(0, len(gj) - extra):] if extra > 0 else []:
solo_js.append(r)
return dict(ok=ok, solo_log=solo_log, solo_jsonl=solo_js, prezzi_divergenti=px_diversi)
def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]:
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty."""
from collections import deque, defaultdict
lotti: dict[str, deque] = defaultdict(deque)
rts: list[dict] = []
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
fee_u = f.fee / f.qty if f.qty else 0.0
if f.side == "buy":
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
else:
resto = f.qty
while resto > 1e-12 and lotti[f.asset]:
lq, lp, lts, lfee_u = lotti[f.asset][0]
usa = min(resto, lq)
lordo = usa * (f.price - lp)
quota_fee = usa * (lfee_u + fee_u)
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
lq -= usa; resto -= usa
if lq <= 1e-12:
lotti[f.asset].popleft()
else:
lotti[f.asset][0][0] = lq
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
return rts, aperti
# =============================================================================================
# persistenza
# =============================================================================================
def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(path)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
return con
def upsert_fills(con: sqlite3.Connection, fills: list[Fill]) -> int:
n = 0
for f in fills:
cur = con.execute(
"""INSERT INTO fills (fill_id, ts_utc, ts_source, bar_ts, asset, side, qty, price, fee,
action, net_target, pos_before, pos_after, tp_frac, skh_sign,
skh_entry, equity, order_id, verified, stato)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(fill_id) DO NOTHING""",
(f.fill_id, f.ts_utc, f.ts_source, f.bar_ts, f.asset, f.side, f.qty, f.price, f.fee,
f.action, f.net_target, f.pos_before, f.pos_after, f.tp_frac, f.skh_sign,
f.skh_entry, f.equity, f.order_id, f.verified, "ok"))
n += cur.rowcount
con.commit()
return n
def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int:
n = 0
for ts, v, src in punti:
cur = con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?) "
"ON CONFLICT(ts_utc) DO NOTHING", (ts, v, src))
n += cur.rowcount
con.commit()
return n
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
price=r["price"], fee=r["fee"]) for r in righe]
rts, _ = fifo_roundtrips(fills)
con.execute("DELETE FROM roundtrips")
con.executemany(
"""INSERT INTO roundtrips (asset, qty, ts_in, px_in, ts_out, px_out, ore_tenuta,
pnl_lordo, fee_quota, pnl_netto)
VALUES (:asset,:qty,:ts_in,:px_in,:ts_out,:px_out,:ore_tenuta,
:pnl_lordo,:fee_quota,:pnl_netto)""", rts)
con.commit()
return len(rts)
def set_meta(con: sqlite3.Connection, k: str, v: str) -> None:
con.execute("INSERT INTO meta (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", (k, v))
con.commit()
def get_meta(con: sqlite3.Connection, k: str, default=None):
r = con.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
return r["v"] if r else default
def stato_aperto(con: sqlite3.Connection) -> dict:
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
price=r["price"], fee=r["fee"]) for r in righe]
_, aperti = fifo_roundtrips(fills)
out = {}
for a, lotti in aperti.items():
q = sum(x["qty"] for x in lotti)
costo = sum(x["qty"] * x["price"] for x in lotti)
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti))
return out
def ora() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")