tradesdb: lotti con segno (short) e fill a qty<=0 fuori dal FIFO (#32 C2, C10)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -125,7 +125,9 @@ def report() -> None:
|
|||||||
if ap:
|
if ap:
|
||||||
print(" posizioni aperte :")
|
print(" posizioni aperte :")
|
||||||
for a, d in sorted(ap.items()):
|
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:
|
else:
|
||||||
print(" posizioni aperte : nessuna (flat)")
|
print(" posizioni aperte : nessuna (flat)")
|
||||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||||
|
|||||||
+36
-16
@@ -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]:
|
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
|
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] = []
|
rts: list[dict] = []
|
||||||
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
|
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
|
fee_u = f.fee / f.qty if f.qty else 0.0
|
||||||
if f.side == "buy":
|
segno_fill = 1 if f.side == "buy" else -1
|
||||||
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
|
dq = lotti[f.asset]
|
||||||
else:
|
|
||||||
resto = f.qty
|
resto = f.qty
|
||||||
while resto > 1e-12 and lotti[f.asset]:
|
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
|
||||||
lq, lp, lts, lfee_u = lotti[f.asset][0]
|
lq, lp, lts, lfee_u, lseg = dq[0]
|
||||||
usa = min(resto, lq)
|
usa = min(resto, lq)
|
||||||
lordo = usa * (f.price - lp)
|
# 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)
|
quota_fee = usa * (lfee_u + fee_u)
|
||||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
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,
|
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,
|
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
|
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
|
||||||
|
direzione=("long" if lseg == 1 else "short")))
|
||||||
lq -= usa; resto -= usa
|
lq -= usa; resto -= usa
|
||||||
if lq <= 1e-12:
|
if lq <= 1e-12:
|
||||||
lotti[f.asset].popleft()
|
dq.popleft()
|
||||||
else:
|
else:
|
||||||
lotti[f.asset][0][0] = lq
|
dq[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}
|
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
|
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:
|
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
|
||||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
|
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
|
||||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
|
||||||
|
`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"],
|
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]
|
price=r["price"], fee=r["fee"]) for r in righe]
|
||||||
rts, _ = fifo_roundtrips(fills)
|
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:
|
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"],
|
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]
|
price=r["price"], fee=r["fee"]) for r in righe]
|
||||||
_, aperti = fifo_roundtrips(fills)
|
_, aperti = fifo_roundtrips(fills)
|
||||||
@@ -366,7 +383,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
|
|||||||
for a, lotti in aperti.items():
|
for a, lotti in aperti.items():
|
||||||
q = sum(x["qty"] for x in lotti)
|
q = sum(x["qty"] for x in lotti)
|
||||||
costo = sum(x["qty"] * x["price"] 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
|
||||||
rts, aperti = T.fifo_roundtrips(fills)
|
rts, aperti = T.fifo_roundtrips(fills)
|
||||||
assert rts == [] # la vendita ETH non chiude il long BTC
|
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():
|
def test_fifo_riproduce_il_conto_del_libro_vero():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user