65a7f0d8fc
Prima, con equity reale non leggibile il giro dimensionava comunque un ordine sul paper_cap ($2.000 nominali) e lo mandava; con SKH ignoto il target restava quello (fail-safe solo nel segno). Ora: se non si sa quanto vale il conto o qual e' il segnale, nessun rebalance_signed — ma il disaster-SL si controlla LO STESSO per ogni asset (dipende da posizione/mark, non da equity/segnale). Giro degradato -> exit 2, solo se armato (non in anteprima dry-run). Fill persi: rebalance_signed puo' eseguire un fill (es. il close di un flip) e poi sollevare nel passo successivo (l'open dall'altra parte) — quel fill spariva perche' la lettura di `newpos`, PRIMA del giro che lo registrava, andava in eccezione a sua volta. Estratta la registrazione in _registra_fill(...), chiamata SUBITO dopo ogni fill (anche quelli di RebalanceParziale, nuova eccezione di execution.py con .fills/.causa) e PRIMA di leggere la posizione; la lettura ora e' in un try separato, non fatale (pos_after=None + "reconcile: pos illeggibile" se fallisce). Aggiornati due test che assumevano il comportamento vecchio (eq_fallback "prosegue" sul paper capital) e il guard sulla sorgente di test_tradesdb.py (bar_ts ora e' un parametro di _registra_fill, non piu' `r['last_data']` inline). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
9.3 KiB
Python
212 lines
9.3 KiB
Python
"""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.
|
|
Dal refactor U2 (#23) la registrazione di un fill vive in `_registra_fill(...)`, che riceve
|
|
`bar_ts` come parametro invece di leggere `r['last_data']` alla riga del log_event: il call
|
|
site passa `bar_ts=r["last_data"]`.
|
|
"""
|
|
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(bar_ts))" in src # dentro _registra_fill
|
|
assert 'bar_ts=r["last_data"]' in src # il call site passa la barra
|
|
assert "ts_utc=datetime.now(timezone.utc)" in src
|