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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:35:08 +00:00
parent 1a7f670966
commit ac21e1c97d
3 changed files with 264 additions and 17 deletions
+49 -13
View File
@@ -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 ---------------------------------------------