From b8c2bba320bdfed3727784bbdd77c7eb795ec88b Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:37:20 +0000 Subject: [PATCH] tradesdb: lotti con segno (short) e fill a qty<=0 fuori dal FIFO (#32 C2, C10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2: fifo_roundtrips ora apre un lotto SHORT quando un sell non trova lotti long da chiudere (prima il resto spariva in silenzio), chiude prima il lato opposto e apre l'eccedenza nel segno del fill — anche a meta' dello stesso fill (inversione long<->short). stato_aperto e report() portano la direzione del lotto residuo. C10: un fill "NON VERIFICATO" a qty 0 (place_order error) non entra piu' nel FIFO ne' in stato_aperto (WHERE qty > 0 in rebuild_roundtrips/stato_aperto); resta nel DB marcato (verified=0), non e' scomparso. test_fifo_non_mescola_gli_asset aggiornato: una vendita ETH senza lotti long ora apre uno short invece di sparire (era il comportamento che C2 corregge). Test nuovi in tests/test_tradesdb_u4.py. Co-Authored-By: Claude Sonnet 5 --- scripts/live/trades_db.py | 4 +- src/live/tradesdb.py | 70 ++++++++++++++-------- tests/test_tradesdb.py | 6 +- tests/test_tradesdb_u4.py | 120 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 tests/test_tradesdb_u4.py diff --git a/scripts/live/trades_db.py b/scripts/live/trades_db.py index f304c95..39b836e 100644 --- a/scripts/live/trades_db.py +++ b/scripts/live/trades_db.py @@ -125,7 +125,9 @@ def report() -> None: 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]})") + marca = "" if d.get("direzione", "long") == "long" else " SHORT" # #32 C2 + print(f" {a}{marca} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} " + f"(dal {d['dal'][:16]})") else: print(" posizioni aperte : nessuna (flat)") print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}") diff --git a/src/live/tradesdb.py b/src/live/tradesdb.py index 2fdf07a..4e075d8 100644 --- a/src/live/tradesdb.py +++ b/src/live/tradesdb.py @@ -262,31 +262,43 @@ def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0. 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.""" + """Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty. + + Lotti CON SEGNO (#32 C2): ogni lotto e' long (+1) o short (-1). Un fill chiude PRIMA i + lotti di segno OPPOSTO (FIFO), e l'eccedenza — se resta qty dopo aver chiuso tutto il + lato opposto — apre (o accoda) un lotto nel segno del fill stesso. Prima di questa + riparazione un SELL senza lotti long da chiudere spariva in silenzio (nessun lotto, + nessun round-trip): l'inversione long -> short in un solo fill e' lo stesso meccanismo, + solo che il lato opposto si esaurisce a meta' fill invece che all'inizio. + """ from collections import deque, defaultdict - lotti: dict[str, deque] = defaultdict(deque) + lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno] 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} + segno_fill = 1 if f.side == "buy" else -1 + dq = lotti[f.asset] + resto = f.qty + while resto > 1e-12 and dq and dq[0][4] == -segno_fill: + lq, lp, lts, lfee_u, lseg = dq[0] + usa = min(resto, lq) + # long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit) + lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price) + 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, + direzione=("long" if lseg == 1 else "short"))) + lq -= usa; resto -= usa + if lq <= 1e-12: + dq.popleft() + else: + dq[0][0] = lq + if resto > 1e-12: # l'eccedenza apre/accoda nel segno del fill + dq.append([resto, f.price, f.ts_utc, fee_u, segno_fill]) + aperti = {a: [dict(qty=q, price=p, ts=t, direzione=("long" if s == 1 else "short")) + for q, p, t, _, s in dq] for a, dq in lotti.items() if dq} return rts, aperti @@ -332,8 +344,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) 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() + """Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano. + + `qty > 0` (#32 C10): un fill "NON VERIFICATO" da `place_order error` puo' arrivare a + qty 0 — non e' un trade, resta nel DB marcato (`verified=0`) ma fuori dal FIFO. + """ + righe = con.execute("SELECT * FROM fills WHERE qty > 0 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) @@ -358,7 +374,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None): def stato_aperto(con: sqlite3.Connection) -> dict: - righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall() + # qty > 0 (#32 C10): stessa esclusione di rebuild_roundtrips, un fill a qty 0 non e' posizione. + righe = con.execute("SELECT * FROM fills WHERE qty > 0 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) @@ -366,7 +383,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict: 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)) + # un asset ha un solo segno di lotti aperti alla volta (fifo_roundtrips chiude prima + # il lato opposto): la direzione del primo lotto vale per tutti (#32 C2). + out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti), + direzione=lotti[0]["direzione"]) return out diff --git a/tests/test_tradesdb.py b/tests/test_tradesdb.py index eea0d0d..4b49c49 100644 --- a/tests/test_tradesdb.py +++ b/tests/test_tradesdb.py @@ -116,7 +116,11 @@ def test_fifo_non_mescola_gli_asset(): _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"} + # (#32 C2) una vendita ETH senza lotti long apre un SHORT — non sparisce piu' in silenzio: + # e' comunque isolata dal long BTC, che e' quel che il test verifica. + assert set(aperti) == {"BTC", "ETH"} + assert aperti["BTC"][0]["direzione"] == "long" + assert aperti["ETH"][0]["direzione"] == "short" def test_fifo_riproduce_il_conto_del_libro_vero(): diff --git a/tests/test_tradesdb_u4.py b/tests/test_tradesdb_u4.py new file mode 100644 index 0000000..bdf9a1f --- /dev/null +++ b/tests/test_tradesdb_u4.py @@ -0,0 +1,120 @@ +"""Test nuovi per l'unita' U4 (issue #32 C2, C10) — tradesdb.py. + +Non ripete la suite esistente (`test_tradesdb.py`): quella resta bit-exact sui casi +long-aperto/long-chiuso (vedi il commit). Qui solo cio' che il fix aggiunge: lotti SHORT, +inversione in un fill, e i fill a qty<=0 fuori dal FIFO. +""" +from __future__ import annotations + +import sys +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 + + +def _f(ts, asset, side, qty, price, fee=0.0, verified=1): + return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee, + verified=verified) + + +@pytest.fixture() +def db(tmp_path): + con = T.connect(tmp_path / "t.db") + yield con + con.close() + + +# ---------------------------------------------------------------- short: apertura e chiusura + +def test_una_vendita_senza_lotti_long_apre_un_short(db): + fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0)] + rts, aperti = T.fifo_roundtrips(fills) + assert rts == [] # niente da chiudere: solo apertura + assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) + assert aperti["BTC"][0]["price"] == pytest.approx(100.0) + assert aperti["BTC"][0]["direzione"] == "short" + + +def test_un_acquisto_chiude_il_short_pnl_entry_meno_exit(db): + fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0), # apre short @100 + _f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 90.0, fee=1.0)] # chiude @90 + rts, aperti = T.fifo_roundtrips(fills) + assert len(rts) == 1 + r = rts[0] + assert r["direzione"] == "short" + assert r["px_in"] == 100.0 and r["px_out"] == 90.0 + assert r["pnl_lordo"] == pytest.approx(10.0) # venduto alto, comprato basso: profitto + assert r["fee_quota"] == pytest.approx(2.0) + assert r["pnl_netto"] == pytest.approx(8.0) + assert aperti == {} # flat: nessun lotto residuo + + +def test_chiusura_parziale_di_uno_short_lascia_il_resto_short(db): + fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), + _f("2026-01-02T00:00:00+00:00", "ETH", "buy", 0.25, 80.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(0.25 * (100.0 - 80.0)) + assert aperti["ETH"][0]["qty"] == pytest.approx(0.75) + assert aperti["ETH"][0]["direzione"] == "short" + + +# ---------------------------------------------------------------- inversione in un solo fill + +def test_inversione_long_a_short_in_un_fill_non_scarta_il_resto(db): + fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), # long 1.0 @100 + _f("2026-01-02T00:00:00+00:00", "BTC", "sell", 2.0, 150.0)] # chiude + apre short + rts, aperti = T.fifo_roundtrips(fills) + assert len(rts) == 1 + assert rts[0]["direzione"] == "long" and rts[0]["qty"] == pytest.approx(1.0) + assert rts[0]["pnl_lordo"] == pytest.approx(50.0) # (150-100)*1 + assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) # il resto: 2.0-1.0 + assert aperti["BTC"][0]["price"] == pytest.approx(150.0) + assert aperti["BTC"][0]["direzione"] == "short" # nessun resto scartato + + +def test_inversione_short_a_long_in_un_fill(db): + fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), # short 1.0 @100 + _f("2026-01-02T00:00:00+00:00", "ETH", "buy", 3.0, 90.0)] # chiude + apre long + rts, aperti = T.fifo_roundtrips(fills) + assert len(rts) == 1 + assert rts[0]["direzione"] == "short" and rts[0]["qty"] == pytest.approx(1.0) + assert rts[0]["pnl_lordo"] == pytest.approx(10.0) # (100-90)*1 + assert aperti["ETH"][0]["qty"] == pytest.approx(2.0) # il resto: 3.0-1.0 + assert aperti["ETH"][0]["direzione"] == "long" + + +def test_stato_aperto_riporta_la_direzione_short(db): + T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]) + st = T.stato_aperto(db) + assert st["ETH"]["qty"] == pytest.approx(1.0) + assert st["ETH"]["direzione"] == "short" + + +# ---------------------------------------------------------------- #32 C10: fill a qty<=0 + +def test_fill_a_qty_zero_non_entra_nel_fifo(db): + T.upsert_fills(db, [ + _f("2026-01-01T00:00:00+00:00", "BTC", "buy", 0.0, 100.0, verified=0), # place_order error + _f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), + _f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)]) + assert T.rebuild_roundtrips(db) == 1 # solo il round-trip vero, non uno spurio + rt = db.execute("SELECT * FROM roundtrips").fetchall() + assert len(rt) == 1 and rt[0]["qty"] == pytest.approx(1.0) + + +def test_fill_a_qty_zero_non_entra_in_stato_aperto_ma_resta_nel_db(db): + T.upsert_fills(db, [ + _f("2026-01-01T00:00:00+00:00", "ETH", "sell", 0.0, 100.0, verified=0), # place_order error + _f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 100.0)]) + st = T.stato_aperto(db) + assert st["ETH"]["qty"] == pytest.approx(1.0) # il fill a qty 0 non conta come lotto + # ma resta leggibile nel DB, marcato NON verificato — non e' sparito, e' escluso dal FIFO + righe = db.execute("SELECT qty, verified FROM fills WHERE qty = 0").fetchall() + assert len(righe) == 1 and righe[0]["verified"] == 0 + assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2