diff --git a/docs/journal/2026-08-25.md b/docs/journal/2026-08-25.md index b1c1d72..3787397 100644 --- a/docs/journal/2026-08-25.md +++ b/docs/journal/2026-08-25.md @@ -1,6 +1,6 @@ # Giornale di bordo — 2026-08-25 -*Scritto 2026-08-26T00:37:44+00:00. Numeri misurati; nessuna interpretazione automatica.* +*Scritto 2026-08-26T07:05:51+00:00. Numeri misurati; nessuna interpretazione automatica.* ## Mercato @@ -20,9 +20,9 @@ Equity **$2,056.59** · nozionale lordo $557 · leva lorda 0.27x · barra dati 2 ## P&L -- giorno: **$+1,414.57** di equity (24 letture) +- giorno: **$+1,414.57** di equity (24 letture) — di cui **$+1,399.39 movimenti di capitale** -> trading **$+15.18** - realizzato: +3.00 netto su 6 round-trip chiusi · 7 fill · fee 0.3452 -- cumulato dall'arming: **$+1,458.53** +- cumulato dall'arming: **$+1,458.53** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+59.14** ## Salute @@ -33,10 +33,11 @@ Equity **$2,056.59** · nozionale lordo $557 · leva lorda 0.27x · barra dati 2 *Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.* +- `[movimento]` 📌 **Movimento di capitale $+1,399.39** fra le 10:47 e le 11:47 UTC: salto +209.6% contro un massimo spiegabile dal mercato di ±0.2% al tetto di leva. Scorporato dal P&L di trading qui sotto. - `[stato]` A mercato su BTC $+345 (TP01 +0.450, SKH01 flat) e ETH $+212 (TP01 +0.274, SKH01 flat). - `[leva]` Leva lorda **0.27x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.73x. -- `[pnl]` Equity $+1414.57 con 7 fill e 6 round-trip chiusi (realizzato $+3.00 netto). -- `[concentrazione]` 📌 **Il 100% di tutto il P&L cumulato ($+1,458.53) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento. +- `[pnl]` P&L di trading $+15.18 (equity $+1414.57 al lordo del movimento di capitale) con 7 fill e 6 round-trip chiusi (realizzato $+3.00 netto). +- `[concentrazione]` 📌 **Il 102% di tutto il P&L cumulato ($+59.14) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento. - `[drawdown]` 📌 Equity **-0.51%** sotto il picco ($2,067.23). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno. - `[vol]` BTC: implicita 43.3 contro realizzata 30g 43.5 (sotto); DVOL al 58° pctl di un anno; IV-rank espandente 0.20 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo). - `[vol]` ETH: implicita 58.1 contro realizzata 30g 69.1 (sotto); DVOL al 48° pctl di un anno; IV-rank espandente 0.20 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo). diff --git a/docs/memory/40-produzione-e-deploy.md b/docs/memory/40-produzione-e-deploy.md index efec585..d7be6e8 100644 --- a/docs/memory/40-produzione-e-deploy.md +++ b/docs/memory/40-produzione-e-deploy.md @@ -790,6 +790,28 @@ SORGENTE** di `cron_daily.sh` — ogni chiamata a `journal.py` deve avere `--gio perche' `journal.py` **senza argomenti scrive OGGI**. La guardia **non vieta** di rigenerare spesso: vieta di scrivere la pagina **una volta e lasciarla li'**. +🚨 **RIPARATO (2026-08-26): il P&L di giornale contava i VERSAMENTI come profitto.** +Il «giorno» era un delta di equity fra letture: la voce del 25/08 dichiarava **+$1.414,57 di +P&L** quando $1.399,39 erano il deposito USDC dell'operatore, e il «cumulato dall'arming» +avrebbe mentito per sempre (e al prossimo versamento da $3.000 avrebbe stampato «+$3.000 di +giorno»). Riparazione in `src/live/journal.py::movimenti_capitale` con tre proprieta': +(1) la **soglia e' importata** da `book.EQUITY_JUMP_ALERT`, non ridichiarata (P1 — il rilevatore +di movimenti del progetto e' quello); (2) un salto e' `movimento` solo se supera di >2x il +massimo che il **mercato misurato** (feed certificato, tetto di leva da config) avrebbe potuto +produrre nell'intervallo fra le due letture; (3) altrimenti e' **`ambiguo`: dichiarato e NON +scorporato** (P12 — un crash vero a tutta leva non deve trasformarsi in "prelievo"). +Scansione dell'intera storia reale: **1 evento, esattamente il deposito** (+209,6% contro un +massimo di mercato di ±0,22%), zero falsi positivi. Anche la regola `concentrazione` ora +lavora sul cumulato di **trading** (il 25/08 leggeva "il 100% del P&L viene dagli ultimi 7 +giorni" su un P&L che era il bonifico). Due scelte di design da non perdere: le regole sui +movimenti stanno **sopra** l'early-return "nessun giro di book" (un versamento in un giorno +senza giri esiste lo stesso: viene dal DB equity, non dal log del libro), e il **limite e' +dichiarato** (D5): un movimento sotto soglia — es. $150 su un conto da $2.000 — non si +distingue dal mercato e resta nel P&L, come nel rilevatore live. 7 prove nuove in +`test_journal.py` (31 → 37), incluso il controllo M15 al contrario: un crash −14% a tutta +leva resta AMBIGUO. Voce del 25/08 **rigenerata**: giorno +$1.414,57 di equity → trading +**+$15,18**; cumulato +$1.458,53 → trading **+$59,14**. + ⚠️ **Trovato girando la suite completa, NON riparato — e' un'altra cosa:** `tests/test_wave_0726.py::test_t1_canonical_riproduce_il_backtest_ufficiale` **fallisce**, e falliva gia' prima di questa modifica (verificato con `git stash`: stesso fallimento sull'albero diff --git a/src/live/journal.py b/src/live/journal.py index ea44777..3f969ec 100644 --- a/src/live/journal.py +++ b/src/live/journal.py @@ -107,8 +107,83 @@ def stato_libro(con, giorno: date) -> dict: azione=v["azione"]) for a, v in ult.stato_asset.items()}) +def _px_1h(asset: str) -> pd.Series | None: + """Chiusure orarie UTC dal feed certificato; None se non leggibile (P5: si dichiara).""" + try: + from src.data.downloader import load_data + df = load_data(asset, "1h") + s = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))["close"] + return s.sort_index() + except Exception: + return None + + +def movimenti_capitale(con, fino_ts: str) -> dict: + """Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L. + + PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce + dentro come se fosse profitto: la voce del 2026-08-25 dichiarava «giorno: +$1.414,57» quando + $1.399 erano un deposito USDC, e il «cumulato dall'arming» avrebbe mentito per sempre. + + COME CLASSIFICA (tre stati, mai due): + - candidato = salto fra due letture consecutive oltre `EQUITY_JUMP_ALERT` del book — + la soglia e' IMPORTATA, non ridichiarata (P1: il rilevatore di movimenti e' quello); + - `movimento` solo se il salto supera di >2x il massimo che il MERCATO avrebbe potuto + produrre nell'intervallo: |Δprezzo| reale dal feed certificato x il tetto di leva letto + da config. Non un'ipotesi sul mercato: il mercato misurato in quella finestra; + - `ambiguo` altrimenti, o quando il mercato non e' misurabile: si DICHIARA e non si + scorpora (P12 — una riparazione silenziosa e' un'invenzione; un crash vero a tutta + leva non deve trasformarsi in "prelievo"). + + ⚠️ LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000) + non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live. + """ + from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata + rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc", + (fino_ts,)).fetchall() + soglia = float(_book.EQUITY_JUMP_ALERT) + candidati = [(a, b) for a, b in zip(rows, rows[1:]) + if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia] + if not candidati: + return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia) + try: + cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) + leva_tetto = float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) + except Exception: + leva_tetto = 1.0 # il tetto di progetto: bound conservativo, usato solo qui + px = {a: _px_1h(a) for a in ASSETS} + eventi = [] + for prev, cur in candidati: + t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"]) + delta = float(cur["equity"] - prev["equity"]) + pct = delta / prev["equity"] + mosse = [] + for a in ASSETS: + s = px.get(a) + if s is None or s.empty: + continue + p0, p1 = s.asof(t0), s.asof(t1) + if p0 and p1 and not (pd.isna(p0) or pd.isna(p1)): + mosse.append(abs(float(p1) / float(p0) - 1.0)) + max_mkt = max(mosse) * leva_tetto if mosse else None + classe = "movimento" if (max_mkt is not None and abs(pct) > 2.0 * max_mkt) else "ambiguo" + eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"], + prima=float(prev["equity"]), dopo=float(cur["equity"]), + delta=delta, pct=float(pct), + mercato_max_pct=(None if max_mkt is None else float(max_mkt)), + classe=classe)) + return dict(eventi=eventi, + certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")), + ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")), + soglia=soglia, leva_tetto=leva_tetto) + + def pnl_giorno(con, giorno: date) -> dict: - """P&L del giorno. `equity` e' l'autorita' (venue); il resto e' scomposizione locale.""" + """P&L del giorno. `equity` e' l'autorita' (venue); il resto e' scomposizione locale. + + `delta_equity` e `cumulato_da_arming` restano FATTI (l'equity si e' mossa cosi'); + `trading_giorno` e `trading_da_arming` sono gli stessi numeri al netto dei movimenti + di capitale CERTI — gli ambigui restano dentro, dichiarati a parte.""" g = giorno.isoformat() eq = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc LIKE ? ORDER BY ts_utc", (f"{g}%",)).fetchall() @@ -119,15 +194,23 @@ def pnl_giorno(con, giorno: date) -> dict: rt = con.execute("SELECT * FROM roundtrips WHERE ts_out LIKE ?", (f"{g}%",)).fetchall() fills = con.execute("SELECT * FROM fills WHERE ts_utc LIKE ?", (f"{g}%",)).fetchall() e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone() + delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None + cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None + mv = movimenti_capitale(con, f"{g}T23:59:59+00:00") + ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g] + mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento") + amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo") return dict( - equity_inizio=e_ini, equity_fine=e_fin, - delta_equity=(e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None, + equity_inizio=e_ini, equity_fine=e_fin, delta_equity=delta, realizzato_lordo=sum(r["pnl_lordo"] for r in rt) if rt else 0.0, realizzato_netto=sum(r["pnl_netto"] for r in rt) if rt else 0.0, roundtrip_chiusi=len(rt), fill=len(fills), fee=sum(f["fee"] for f in fills) if fills else 0.0, - cumulato_da_arming=(e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None, - letture_equity=len(eq)) + cumulato_da_arming=cum, letture_equity=len(eq), + movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g), + trading_giorno=(delta - mov_g) if delta is not None else None, + movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"], + trading_da_arming=(cum - mv["certi"]) if cum is not None else None) def salute(con, giorno: date) -> dict: @@ -201,7 +284,25 @@ def lettura(con, voce: dict) -> list[dict]: def dire(rid, testo, livello="info"): out.append(dict(id=rid, testo=testo, livello=livello)) + # --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ---------------------- + for e in p.get("movimenti_giorno") or []: + h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16] + if e["classe"] == "movimento": + dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le " + f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile " + f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. " + "Scorporato dal P&L di trading qui sotto.", "nota") + else: + perche = ("il mercato non e' misurabile in quella finestra" if e["mercato_max_pct"] + is None else f"il mercato avrebbe potuto produrlo (max " + f"±{100*e['mercato_max_pct']:.1f}% al tetto di leva)") + dire("movimento_ambiguo", f"Salto di equity **${e['delta']:+,.2f}** fra le {h0} e le " + f"{h1} UTC che NON si puo' attribuire: {perche}. " + "Resta nel P&L, dichiarato — non scorporato.", "attenzione") + # --- cosa il libro STA FACENDO ----------------------------------------------------------- + # (le regole sui movimenti stanno SOPRA questo return: un versamento in un giorno senza + # giri di book esiste lo stesso — viene dal DB equity, non dal log del libro) if "errore" in lb: dire("stato", f"Nessun giro di `book_execute`: {lb['errore']}. " "Non e' «il libro era fermo», e' «non e' stato misurato».", "attenzione") @@ -264,16 +365,23 @@ def lettura(con, voce: dict) -> list[dict]: # --- P&L del giorno, in proporzione ------------------------------------------------------ dq, fee = p.get("delta_equity"), p.get("fee", 0.0) - if dq is not None: - if p["fill"] and fee > 0 and abs(dq) < fee: - dire("fee", f"Il movimento di equity del giorno (${dq:+.2f}) e' **piu' piccolo delle " - f"fee pagate** (${fee:.4f}): la giornata l'ha decisa il costo, non il segnale.", - "nota") + dq_t = p.get("trading_giorno") + dq_eff = dq_t if dq_t is not None else dq + scorporo = bool(p.get("mov_giorno")) + if dq_eff is not None: + if p["fill"] and fee > 0 and abs(dq_eff) < fee: + dire("fee", f"Il movimento di equity del giorno (${dq_eff:+.2f}" + f"{', al netto dei movimenti di capitale' if scorporo else ''}) e' " + f"**piu' piccolo delle fee pagate** (${fee:.4f}): la giornata l'ha " + "decisa il costo, non il segnale.", "nota") elif p["fill"] == 0: - dire("pnl", f"Equity ${dq:+.2f} **senza operare**: e' mark-to-market sulle posizioni " - "gia' aperte.") + dire("pnl", f"Equity ${dq_eff:+.2f} **senza operare**: e' mark-to-market sulle " + "posizioni gia' aperte." + + (" (Al netto dei movimenti di capitale.)" if scorporo else "")) else: - dire("pnl", f"Equity ${dq:+.2f} con {p['fill']} fill e " + dire("pnl", (f"P&L di trading ${dq_eff:+.2f} (equity ${dq:+.2f} al lordo del " + f"movimento di capitale) " if scorporo else f"Equity ${dq_eff:+.2f} ") + + f"con {p['fill']} fill e " f"{p['roundtrip_chiusi']} round-trip chiusi (realizzato " f"${p['realizzato_netto']:+.2f} netto).") @@ -282,15 +390,19 @@ def lettura(con, voce: dict) -> list[dict]: (voce["giorno"] + "T23:59:59+00:00",)).fetchall() if len(eq) > 24: e0, e1 = eq[0]["equity"], eq[-1]["equity"] - tot = e1 - e0 + # 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] >= (date.fromisoformat(voce["giorno"]) - - timedelta(days=7)).isoformat()] + 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) # 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"]) / tot + quota = (e1 - g7[0]["equity"] - mv7) / tot if quota > 0.60: coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: " "e' un evento.") @@ -424,12 +536,24 @@ def rendi_markdown(con, voce: dict) -> str: sk += " @ {:,.1f}".format(v["skh_entry"]) L.append(f"| **{a}** | {v['tp_frac']:+.3f} | {sk} | ${v['target']:+,.0f} | " f"${v['posizione']:+,.0f} | {v['azione']} |") + riga_g = (f"- giorno: **{_n(p.get('delta_equity'),'${:+,.2f}')}** di equity " + f"({p['letture_equity']} letture)") + if p.get("mov_giorno"): + riga_g += (f" — di cui **${p['mov_giorno']:+,.2f} movimenti di capitale** -> trading " + f"**{_n(p.get('trading_giorno'),'${:+,.2f}')}**") + if p.get("mov_giorno_ambiguo"): + riga_g += f" — ⚠️ salto ambiguo di ${p['mov_giorno_ambiguo']:+,.2f} non attribuito" + riga_c = f"- cumulato dall'arming: **{_n(p.get('cumulato_da_arming'),'${:+,.2f}')}** di equity" + if p.get("mov_arming"): + riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading " + f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**") + if p.get("mov_arming_ambiguo"): + riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti" L += ["", "## P&L", "", - f"- giorno: **{_n(p.get('delta_equity'),'${:+,.2f}')}** di equity " - f"({p['letture_equity']} letture)", + riga_g, f"- realizzato: {p['realizzato_netto']:+.2f} netto su {p['roundtrip_chiusi']} round-trip " f"chiusi · {p['fill']} fill · fee {p['fee']:.4f}", - f"- cumulato dall'arming: **{_n(p.get('cumulato_da_arming'),'${:+,.2f}')}**", "", + riga_c, "", "## Salute", "", f"- giri di `book_execute`: {s['giri_book']}/{s['atteso_giri']}" + (" *(giorno in corso)*" if s.get("giorno_in_corso") else "") diff --git a/tests/test_journal.py b/tests/test_journal.py index 4f06261..5c0db41 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -358,3 +358,112 @@ def test_il_cron_non_apre_piu_la_voce_del_giorno_in_corso(): assert chiamate, "il giornale non gira piu' nel cron: e' un'altra rottura, non questa" for r in chiamate: assert "--giorno" in r, f"apre un giorno implicito (= oggi, parziale): {r}" + + +# ------------------------------------------------- movimenti di capitale nel P&L +# Il difetto (2026-08-25): la voce dichiarava «giorno: +$1.414,57» quando $1.399 erano un +# deposito USDC — il P&L era un delta di equity e ogni versamento ci finiva dentro come +# profitto, per sempre (il cumulato dall'arming non si sarebbe mai piu' corretto). + +def _px_piatto(livello=100.0): + ix = pd.date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC") + return pd.Series(livello, index=ix) + + +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 test_un_versamento_viene_rilevato_e_scorporato(db, monkeypatch): + monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) + _serie_equity(db, [("2026-08-25T09:47:01+00:00", 667.0), + ("2026-08-25T10:47:01+00:00", 667.5), + ("2026-08-25T11:47:01+00:00", 2066.5), + ("2026-08-25T12:47:01+00:00", 2064.0)]) + p = J.pnl_giorno(db, date(2026, 8, 25)) + ev = p["movimenti_giorno"] + assert len(ev) == 1 and ev[0]["classe"] == "movimento" + assert p["mov_giorno"] == pytest.approx(1399.0) + # il delta resta il FATTO, il trading e' il netto + assert p["delta_equity"] == pytest.approx(2064.0 - 667.0) + assert p["trading_giorno"] == pytest.approx(p["delta_equity"] - 1399.0) + assert p["trading_da_arming"] == pytest.approx(p["cumulato_da_arming"] - 1399.0) + + +def test_il_markdown_scorpora_e_lo_dice(db, monkeypatch): + monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.0), + ("2026-08-25T11:47:01+00:00", 2066.0)]) + voce = _voce(pnl=J.pnl_giorno(db, date(2026, 8, 25)), giorno="2026-08-25") + md = J.rendi_markdown(db, voce) + assert "movimenti di capitale" in md and "trading" in md + righe = J.lettura(db, voce) + assert "movimento" in _ids(righe) + + +def test_un_crash_a_tutta_leva_resta_AMBIGUO_e_non_diventa_prelievo(db, monkeypatch): + # controllo positivo al contrario (M15): il mercato fa -15% nell'ora, il libro al tetto + # di leva (1.0x) puo' spiegare il -14% di equity -> NON e' un prelievo, e' dichiarato. + ix = pd.date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC") + px = pd.Series(100.0, index=ix) + px[ix >= "2026-08-25T11:00:00+00:00"] = 85.0 + monkeypatch.setattr(J, "_px_1h", lambda a: px) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0), + ("2026-08-25T11:47:01+00:00", 516.0)]) + p = J.pnl_giorno(db, date(2026, 8, 25)) + assert p["movimenti_giorno"][0]["classe"] == "ambiguo" + assert p["mov_giorno"] == 0.0 # niente scorporo + assert p["trading_giorno"] == pytest.approx(p["delta_equity"]) + righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25")) + amb = [r for r in righe if r["id"] == "movimento_ambiguo"] + assert amb and amb[0]["livello"] == "attenzione" + + +def test_sotto_soglia_niente_eventi_e_trading_uguale_al_delta(db, monkeypatch): + monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0), + ("2026-08-25T11:47:01+00:00", 612.0)]) # +2%: sotto EQUITY_JUMP_ALERT + p = J.pnl_giorno(db, date(2026, 8, 25)) + assert p["movimenti_giorno"] == [] and p["mov_giorno"] == 0.0 + assert p["trading_giorno"] == pytest.approx(p["delta_equity"]) + md = J.rendi_markdown(db, _voce(pnl=p, giorno="2026-08-25")) + assert "movimenti di capitale" not in md + + +def test_la_soglia_e_DERIVATA_dal_book_non_riscritta(db, monkeypatch): + # P1: se il rilevatore del book cambia soglia, il giornale la segue senza essere toccato. + from src.live import book as B + monkeypatch.setattr(B, "EQUITY_JUMP_ALERT", 0.50) + monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0), + ("2026-08-25T11:47:01+00:00", 780.0)]) # +30%: sotto la soglia alzata + p = J.pnl_giorno(db, date(2026, 8, 25)) + assert p["movimenti_giorno"] == [] + + +def test_mercato_non_misurabile_e_ambiguo_dichiarato_non_movimento(db, monkeypatch): + # P5: "non vedo" non e' "va tutto bene" — senza feed il salto NON si attribuisce. + monkeypatch.setattr(J, "_px_1h", lambda a: None) + _serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.0), + ("2026-08-25T11:47:01+00:00", 2066.0)]) + p = J.pnl_giorno(db, date(2026, 8, 25)) + e = p["movimenti_giorno"][0] + assert e["classe"] == "ambiguo" and e["mercato_max_pct"] is None + righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25")) + assert any(r["id"] == "movimento_ambiguo" and "non e' misurabile" in r["testo"] for r in righe) + + +def test_la_concentrazione_non_conta_i_versamenti_come_pnl(db, monkeypatch): + # 30 giorni piatti a 600, poi un versamento da 1400: senza scorporo la regola direbbe + # "tutto il P&L viene dagli ultimi 7 giorni" su un P&L che non esiste. + monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) + punti = [(f"2026-07-{g:02d}T12:00:00+00:00", 600.0) for g in range(1, 32)] + punti += [(f"2026-08-{g:02d}T12:00:00+00:00", 600.0) for g in range(1, 25)] + punti += [("2026-08-25T11:47:01+00:00", 2000.0)] + _serie_equity(db, punti) + p = J.pnl_giorno(db, date(2026, 8, 25)) + righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25")) + conc = [r for r in righe if r["id"] == "concentrazione"] + assert conc == [] # il cumulato di TRADING e' 0: la regola deve tacere