From ac21e1c97dd1ee9927bae0c763a1a8bd751a66fe Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:35:08 +0000 Subject: [PATCH 1/3] journal: dichiarati nello stesso intervallo si sommano, giro corrente, finestra 7gg, DD netto (#27, #35) #27: due movimenti dichiarati nello stesso intervallo si sommavano al posto di sovrascriversi (l'aggancio "dichiarato+rilevato" ora cerca solo fonte=="rilevato"). #35 C9: salute() non conta piu' il giro dell'ora corrente prima che scatti (minuto :47 + margine, MINUTO_GIRO costante di modulo). #35 C15: la finestra "ultimi 7 giorni" della concentrazione ora copre 7 giorni di calendario, non 8 (baseline = ultima lettura al o prima di giorno-7 fine giornata). #35 C7: il drawdown si calcola sulla serie equity al netto dei movimenti di capitale certi, cosi' un prelievo non si legge come perdita di trading e un versamento non alza il picco. Test nuovi in tests/test_journal_u4.py; tests/test_journal.py aggiornato (l'orologio del test su salute() era reale e non deterministico, ora e' fissato). Co-Authored-By: Claude Sonnet 5 --- src/live/journal.py | 62 +++++++++--- tests/test_journal.py | 12 ++- tests/test_journal_u4.py | 207 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 tests/test_journal_u4.py diff --git a/src/live/journal.py b/src/live/journal.py index d941e3d..d500cd4 100644 --- a/src/live/journal.py +++ b/src/live/journal.py @@ -248,12 +248,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict: + (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "") + (f": {d['nota']}" if d["nota"] else "") + (f" (banda ±${d['banda']:,.2f})" if d["banda"] else "")) - gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None) - if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato - gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None, + # (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si + # cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso + # intervallo troverebbe il primo (gia' "dichiarato") e lo SOVRASCRIVEREBBE, perdendo + # il primo importo invece di somarli. + rilevato = next((e for e in eventi + if e["ts_dopo"] == cur["ts_utc"] and e["fonte"] == "rilevato"), None) + if rilevato is not None: # rilevato E dichiarato: l'importo e' quello dichiarato + rilevato.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None, classe="movimento", fonte="dichiarato+rilevato", dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d) continue + gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"] + and e["fonte"] in ("dichiarato", "dichiarato+rilevato")), None) + if gia is not None: # un altro dichiarato nello stesso intervallo: si SOMMA + nuovo_delta = gia["delta"] + d["delta"] + gia.update(delta=nuovo_delta, + pct=(nuovo_delta / float(gia["prima"]) if gia["prima"] else None), + dopo=float(gia["prima"]) + nuovo_delta, + dichiarazione=(gia["dichiarazione"] + "; " + nota_d)) + continue eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"], prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"], delta=d["delta"], @@ -376,16 +390,23 @@ def pnl_giorno(con, giorno: date) -> dict: twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"]) +MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9) + + 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. + + Il giro dell'ora CORRENTE scatta al minuto `MINUTO_GIRO` (il cron e' `47 * * * *`): prima + di quel minuto non e' ancora avvenuto, e contarlo comunque (#35 C9) segnalava un giro + "mancante" che semplicemente non era ancora il suo turno. """ lb = stato_libro(con, giorno) adesso = datetime.now(timezone.utc) in_corso = giorno == adesso.date() - atteso = (adesso.hour + 1) if in_corso else 24 + atteso = (adesso.hour + (1 if adesso.minute >= MINUTO_GIRO + 1 else 0)) 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)) @@ -591,16 +612,19 @@ def lettura(con, voce: dict) -> list[dict]: # il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento # diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto) mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"] - g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() tot = e1 - e0 - sum(e["delta"] for e in mv_ev) - picco = max(r["equity"] for r in eq) - g7 = [r for r in eq if r["ts_utc"][:10] >= g7s] - mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s) + # (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o + # prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per + # data (>= giorno-7) include anche le ore del giorno-7 gia' trascorse, cioe' 8 giorni di + # calendario, non 7. + g7_cutoff = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() + "T23:59:59+00:00" + base7 = next((r for r in reversed(eq) if r["ts_utc"] <= g7_cutoff), None) + mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"] > g7_cutoff) # Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica # vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione. rilevante = abs(tot) >= max(5.0, 0.01 * e1) - if rilevante and g7: - quota = (e1 - g7[0]["equity"] - mv7) / tot + if rilevante and base7 is not None: + quota = (e1 - base7["equity"] - mv7) / tot if quota > 0.60: coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: " "e' un evento.") @@ -609,10 +633,22 @@ def lettura(con, voce: dict) -> list[dict]: "senza questi 7 giorni il libro sarebbe sotto. " + coda) dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato " f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota") - if picco > 0: - dd = (e1 - picco) / picco + # (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il + # TWR scorpora): sulla serie grezza un prelievo si legge come drawdown di trading e un + # versamento alza il picco, mascherando un DD vero appena prima del versamento. + mv_ord = sorted(mv_ev, key=lambda e: e["ts_dopo"]) + cum_mov, i_mov, eq_netto = 0.0, 0, [] + for r in eq: + while i_mov < len(mv_ord) and r["ts_utc"] >= mv_ord[i_mov]["ts_dopo"]: + cum_mov += mv_ord[i_mov]["delta"] + i_mov += 1 + eq_netto.append(r["equity"] - cum_mov) + picco_netto = max(eq_netto) + if picco_netto > 0: + dd = (eq_netto[-1] - picco_netto) / picco_netto if dd < -0.005: - dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). " + dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco_netto:,.2f}, " + "al netto dei movimenti di capitale certi). " "⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota") # --- volatilita' implicita contro realizzata --------------------------------------------- diff --git a/tests/test_journal.py b/tests/test_journal.py index 8b44e1c..07958eb 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -31,12 +31,16 @@ def db(tmp_path): def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch): """24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore.""" - oggi = datetime.now(timezone.utc).date() - monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1, - feed_skh_min=0)) + class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira + @classmethod + def now(cls, tz=None): + return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc) + monkeypatch.setattr(J, "datetime", _OraFissa) + oggi = _OraFissa.now(timezone.utc).date() + monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0)) s = J.salute(db, oggi) assert s["giorno_in_corso"] is True - assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1 + assert s["atteso_giri"] == 15 # ore 14:50, il giro delle 14 (:47) e' scattato assert s["giri_mancanti"] == 0 diff --git a/tests/test_journal_u4.py b/tests/test_journal_u4.py new file mode 100644 index 0000000..a2b5123 --- /dev/null +++ b/tests/test_journal_u4.py @@ -0,0 +1,207 @@ +"""Test nuovi per l'unita' U4 (issue #27, #35 C9/C15/C7) — journal.py. + +Non ripete la suite esistente: la tocca solo dove il comportamento CAMBIA. +""" +from __future__ import annotations + +import json +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 journal as J # noqa: E402 +from src.live import tradesdb as T # noqa: E402 + + +@pytest.fixture() +def db(tmp_path): + con = T.connect(tmp_path / "t.db") + yield con + con.close() + + +def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"): + return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {}, + libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {}, + salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24, + giorno_in_corso=False, giri_mancanti=0)) + + +def _libro(**asset): + a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0, + azione="HOLD (a target)"), **v) for k, v in asset.items()} + lordo = sum(abs(v["posizione"]) for v in a.values()) + return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21", + feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a) + + +def _pnl(**kw): + base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0, + realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0, + cumulato_da_arming=0.0, letture_equity=24) + return {**base, **kw} + + +def _ids(righe): + return [r["id"] for r in righe] + + +def _serie_equity(con, punti): + for ts, eq in punti: + con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test")) + con.commit() + + +def _dichiara(monkeypatch, tmp_path, righe): + f = tmp_path / "mov.jsonl" + f.write_text("\n".join(json.dumps(r) for r in righe) + "\n") + monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f) + return f + + +# ============================================================================================ +# #27 — due dichiarati nello stesso intervallo si SOMMANO, non si sovrascrivono +# ============================================================================================ + +def test_due_dichiarati_nello_stesso_intervallo_si_sommano(db, monkeypatch, tmp_path): + """+25 e +30 dichiarati nello stesso intervallo, trading reale +5: prima del fix il + secondo dichiarato sovrascriveva il primo (delta finale 30, non 55).""" + _serie_equity(db, [("2026-08-25T07:07:01+00:00", 1000.0), + ("2026-08-25T08:07:01+00:00", 1060.0)]) # +60 = 25+30 mov. +5 trading + _dichiara(monkeypatch, tmp_path, [ + dict(ts_utc="2026-08-25T07:20:00+00:00", delta_usd=25.0, nota="primo versamento"), + dict(ts_utc="2026-08-25T07:40:00+00:00", delta_usd=30.0, nota="secondo versamento")]) + r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00") + assert len(r["eventi"]) == 1 # UN evento, non due + e = r["eventi"][0] + assert e["fonte"] == "dichiarato" and e["delta"] == pytest.approx(55.0) # 25+30, non 30 + assert e["dopo"] == pytest.approx(1055.0) + assert "primo versamento" in e["dichiarazione"] and "secondo versamento" in e["dichiarazione"] + assert r["certi"] == pytest.approx(55.0) + assert r["trading"] == pytest.approx(5.0) # 1060-1000-55 + # solo due letture: l'intero intervallo e' il movimento, nessun segmento di trading — 0% + # e' il limite DICHIARATO di rendimento_twr ("nessun segmento = nessun tempo a mercato"), + # non un valore inventato. + assert r["twr"] == pytest.approx(0.0) + + +def test_dichiarato_su_rilevato_resta_come_prima_anche_con_un_altro_dichiarato_altrove(db, monkeypatch, tmp_path): + """Non regressione: l'aggancio dichiarato+rilevato non deve rompersi perche' ora si guarda + anche ad altri dichiarati.""" + monkeypatch.setattr(J, "_px_1h", lambda a: __import__("pandas").Series( + 100.0, index=__import__("pandas").date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC"))) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49), + ("2026-08-25T11:47:01+00:00", 2066.88), + ("2026-08-25T12:47:01+00:00", 2064.56)]) + _dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39, + nota="bonifico")]) + r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00") + assert len(r["eventi"]) == 1 + e = r["eventi"][0] + assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39) + + +# ============================================================================================ +# #35 C9 — il giro dell'ora corrente conta solo dopo che e' scattato (minuto :47 + margine) +# ============================================================================================ + +def test_atteso_giri_al_minuto_10_non_conta_ancora_il_giro_dell_ora_corrente(db, monkeypatch): + class _OraFissa(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 8, 25, 14, 10, tzinfo=timezone.utc) + monkeypatch.setattr(J, "datetime", _OraFissa) + monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=14, feed_skh_min=0)) + s = J.salute(db, date(2026, 8, 25)) + assert s["atteso_giri"] == 14 # il giro delle 14 (:47) non e' ancora scattato + assert s["giri_mancanti"] == 0 + + +def test_atteso_giri_al_minuto_50_conta_il_giro_dell_ora_corrente(db, monkeypatch): + class _OraFissa(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc) + monkeypatch.setattr(J, "datetime", _OraFissa) + monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0)) + s = J.salute(db, date(2026, 8, 25)) + assert s["atteso_giri"] == 15 # dopo le :47 il giro delle 14 e' scattato + assert s["giri_mancanti"] == 0 + + +# ============================================================================================ +# #35 C15 — "ultimi 7 giorni" deve coprire 7 giorni di calendario, non 8 +# ============================================================================================ + +def test_la_finestra_di_concentrazione_e_di_7_giorni_non_8(db): + base = date(2026, 6, 24) # base .. base+13 = 14 giorni prima del giorno + righe = [] + for i in range(7): # giorni 0..6: piatto + d = (base + timedelta(days=i)).isoformat() + righe += [(f"{d}T06:00:00+00:00", 1000.0), (f"{d}T18:00:00+00:00", 1000.0)] + d7 = (base + timedelta(days=7)).isoformat() # giorno-7: il salto avviene QUI, a meta' giornata + righe += [(f"{d7}T06:00:00+00:00", 1000.0), (f"{d7}T18:00:00+00:00", 1020.0)] + for i in range(8, 14): # giorni 8..13: piatto al nuovo livello + d = (base + timedelta(days=i)).isoformat() + righe += [(f"{d}T06:00:00+00:00", 1020.0), (f"{d}T18:00:00+00:00", 1020.0)] + d14 = (base + timedelta(days=14)).isoformat() # il giorno della voce + righe += [(f"{d14}T06:00:00+00:00", 1020.0), (f"{d14}T18:00:00+00:00", 1100.0)] + _serie_equity(db, righe) + assert len(righe) == 30 # >24: la sezione si attiva + v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[]), giorno=d14) + out = J.lettura(db, v) + riga = next(r for r in out if r["id"] == "concentrazione") + # baseline corretta = ULTIMA lettura di giorno-7 (1020, alle 18:00), non la PRIMA (1000, + # alle 06:00): (1100-1020)/(1100-1000) = 80%. Con la finestra sbagliata (8 giorni di + # calendario) sarebbe (1100-1000)/100 = 100%. + assert "80%" in riga["testo"] + assert "100%" not in riga["testo"] + + +# ============================================================================================ +# #35 C7 — il drawdown si legge sulla serie AL NETTO dei movimenti certi +# ============================================================================================ + +def test_un_prelievo_non_diventa_un_drawdown_di_trading(db): + base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + punti = [base + timedelta(hours=i) for i in range(26)] # 26 letture, >24 + for t in punti[:-1]: + db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), 1000.0, "t")) + db.execute("INSERT INTO equity VALUES (?,?,?)", (punti[-1].isoformat(), 800.0, "t")) + db.commit() + mov = dict(classe="movimento", delta=-200.0, ts_prima=punti[-2].isoformat(), + ts_dopo=punti[-1].isoformat(), prima=1000.0, dopo=800.0, pct=-0.2, + fonte="dichiarato", dichiarazione="prelievo di prova") + v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]), + giorno=punti[-1].date().isoformat()) + out = J.lettura(db, v) + # sulla serie grezza (picco 1000, ultima 800) sarebbe -20%: un prelievo, non una perdita. + assert "drawdown" not in _ids(out) + + +def test_un_versamento_non_nasconde_un_drawdown_di_trading_vero(db): + base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + letture = [(base + timedelta(hours=i), 1000.0) for i in range(20)] # piatto, 20 letture + letture.append((base + timedelta(hours=20), 1500.0)) # versamento +500 + for h, v in ((21, 1490.0), (22, 1480.0), (23, 1475.0), (24, 1470.0)): # trading in calo + letture.append((base + timedelta(hours=h), v)) + for t, val in letture: + db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), val, "t")) + db.commit() + assert len(letture) == 25 # >24 + mov = dict(classe="movimento", delta=500.0, ts_prima=letture[19][0].isoformat(), + ts_dopo=letture[20][0].isoformat(), prima=1000.0, dopo=1500.0, pct=0.5, + fonte="dichiarato", dichiarazione="versamento di prova") + v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]), + giorno=letture[-1][0].date().isoformat()) + out = J.lettura(db, v) + riga = next(r for r in out if r["id"] == "drawdown") + # netto: picco 1000 (prima/al versamento), ultima 970 (1470-500) -> -3.00%. Sulla serie + # grezza (picco 1500 gonfiato dal versamento) sarebbe -2.00%: sottostima il vero calo. + assert "-3.00%" in riga["testo"] + assert "-2.00%" not in riga["testo"] From b8c2bba320bdfed3727784bbdd77c7eb795ec88b Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:37:20 +0000 Subject: [PATCH 2/3] 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 From 0c19c804d1a37c46de163c8baafe4491f0fae90f Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:38:10 +0000 Subject: [PATCH 3/3] analista: --secco non scrive piu' nel DB (#34) scrivi() chiamava J.salva() prima del controllo su --secco: un giro "a secco" scriveva comunque le sezioni misurate nel giornale. Ora salva() (e scrivi_file, che era gia' dopo il return) girano solo quando non e' secco; rendi_markdown() tollera l'assenza della riga in DB (nota/analisi vuote, verificato). Test nuovo in tests/test_analista.py (non c'e' un test_analista_u4.py: l'analisi indicava i due file journal/tradesdb, questo fix e' su analista.py e segue la convenzione un file-di-test-per-modulo del resto della suite). Co-Authored-By: Claude Sonnet 5 --- scripts/live/analista.py | 3 ++- tests/test_analista.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/live/analista.py b/scripts/live/analista.py index 4ddbfd7..2fcbe0c 100644 --- a/scripts/live/analista.py +++ b/scripts/live/analista.py @@ -73,7 +73,8 @@ def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str], def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True, telegram: bool = True) -> str: voce = J.costruisci(con, g) - J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge + if not secco: # --secco: stampa e NON salva (#34) — niente scrittura + J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge pagina = J.rendi_markdown(con, voce) st = storico(con, g) prompt = A.costruisci_prompt(pagina, st) diff --git a/tests/test_analista.py b/tests/test_analista.py index be6760d..d24eec5 100644 --- a/tests/test_analista.py +++ b/tests/test_analista.py @@ -210,6 +210,21 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path): assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok" +def test_secco_stampa_e_non_salva(db, monkeypatch, tmp_path, capsys): + """#34: prima del fix `J.salva()` veniva chiamato comunque, prima del controllo su + `secco` — un giro "a secco" scriveva lo stesso le sezioni misurate nel DB.""" + import analista as CLI + monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi che non deve finire nel DB.", None)) + monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path) + g = date(2026, 8, 21) + assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB vuoto in partenza + stato = CLI.scrivi(db, g, modello="finto", secco=True, verbose=False, telegram=False) + assert stato == "ok" + capsys.readouterr() # non e' l'oggetto del test + assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB ANCORA vuoto + assert not (tmp_path / f"{g}.md").exists() # scrivi_file non chiamato + + def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path): """Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi.""" import analista as CLI