"""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"]