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