monitor: advance() consuma solo barre CHIUSE — serie rigenerate, guardia PREMATURO, 4 debiti chiusi
S5.1 RIPARATO E RIGENERATO. Filtro condiviso src/live/paper_guard.py (barra open-labeled chiusa = ts + cadenza <= adesso) importato da tutti e 6 i monitor; serie rigenerate dallo stesso start_ts con scripts/live/paper_regen.py (evidenza in *.pre_regen_20260826.*): statarb +1,95 -> -1,61 (il ribaltamento del gate 27/09 previsto dall'audit), dvolspread -14,73 -> -4,41, xsr -4,98 -> -2,72, prevday invariato. Nessuna data di gate si sposta. Guardia cablata in monitor_health: stato PREMATURO (ultima barra che chiude dopo l'mtime, grazia 5 min, open_labeled=False per collect_chain) — sul dato vivo segnala i 5 rotti e tace sui 2 sani; dopo la rigenerazione 7/7 OK. paper_portfolio non rigenerato (GTAA su ADJUSTED_LAST: replay != serie registrata, P12), tolta la coda non chiusa. D6 pagata di nuovo nel fix: asi8 in pandas 3 e' in us, non ns — blindata con test su tre risoluzioni. S5.12 ESTESO: conftest devia anche trades.db (wrapper su connect: il default e' catturato alla definizione) e docs/journal/; book_executions.jsonl sorvegliato con impronta inizio/fine suite. S5.5 FATTO: test_leva_massima cancellato con nota (misurava frac*n_asset: con una chiave di scala avrebbe continuato a passare smettendo di controllare). S5.9 INDAGATO E RIPARATO (r0826_skh_band_drift): il dato regge (taglio 02/07 riproduce l'audit 1,6376, in-sample identico su ogni taglio); la deriva era la finestra hold-out — e la sola settimana 15-22/08 vale +0,35 di Sharpe hold-out. Il test ora taglia il feed al 02/07 e verifica la riproduzione stretta. Suite: 751 passati, 0 falliti. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B9UyJLHzR7EJzxR3iQ3RN1
This commit is contained in:
@@ -49,4 +49,44 @@ def _isola_file_operativi_vivi(tmp_path, monkeypatch):
|
||||
"""
|
||||
import src.live.book as book
|
||||
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
|
||||
|
||||
# --- trades.db (2026-08-26, estensione §5.12) --------------------------------------------
|
||||
# ⚠️ `connect(path=DB_PATH)` cattura il default ALLA DEFINIZIONE: patchare la costante
|
||||
# `DB_PATH` non cambierebbe niente. Si avvolge la funzione: un test che passa il path
|
||||
# esplicito (tutti quelli esistenti usano `T.connect(tmp_path / ...)`) non cambia
|
||||
# comportamento; un test che chiamasse `connect()` nudo finisce in tmp, non sul libro
|
||||
# di bordo vivo.
|
||||
import src.live.tradesdb as tradesdb
|
||||
_connect_vero = tradesdb.connect
|
||||
|
||||
def _connect_isolato(path=None):
|
||||
return _connect_vero(tmp_path / "trades.db" if path is None else path)
|
||||
|
||||
monkeypatch.setattr(tradesdb, "connect", _connect_isolato)
|
||||
|
||||
# --- docs/journal/ -----------------------------------------------------------------------
|
||||
# `journal.scrivi_file` scrive pagine markdown TRACCIATE DA GIT: un test che lo chiama
|
||||
# non deve poter sporcare il working tree (P16: il cron gira dal working tree).
|
||||
import src.live.journal as journal
|
||||
monkeypatch.setattr(journal, "JOURNAL_DIR", tmp_path / "journal")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _guardia_log_esecuzioni_vivo():
|
||||
"""`book_executions.jsonl` e' scritto da `book_execute.py`, che i test caricano ad-hoc con
|
||||
importlib: nessun monkeypatch di conftest puo' raggiungerlo PRIMA. Qui si fa l'altra meta'
|
||||
del lavoro: impronta a inizio suite, verifica a fine suite. Se questo assert scatta, un
|
||||
test ha scritto nel registro VIVO delle esecuzioni — trovarlo e dargli un path suo.
|
||||
|
||||
Falso positivo possibile ma raro: un FILL REALE del cron :47 mentre la suite gira
|
||||
(30 fill in 43 giorni). In quel caso l'orario dell'ultima riga del file coincide col
|
||||
giro del cron, non con la suite: verificarlo prima di dare la colpa a un test."""
|
||||
f = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
|
||||
prima = (f.stat().st_mtime_ns, f.stat().st_size) if f.exists() else None
|
||||
yield
|
||||
dopo = (f.stat().st_mtime_ns, f.stat().st_size) if f.exists() else None
|
||||
assert prima == dopo, (
|
||||
"un test ha scritto in data/live/book_executions.jsonl (registro VIVO): "
|
||||
f"{prima} -> {dopo}. Se l'ultima riga del file e' delle :47 puo' essere un fill "
|
||||
"reale del cron; altrimenti trovare il test e isolarlo.")
|
||||
|
||||
@@ -79,17 +79,22 @@ def test_skh01_e_piu_sensibile_alla_fee_di_tp01():
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# guardia di decisione: la leva su cui poggia "liquidation fee irrilevante"
|
||||
# guardia di decisione RIMOSSA (2026-08-26) — e il perche' va letto, non saltato
|
||||
# ===========================================================================
|
||||
def test_leva_massima_da_config_resta_sotto_o_uguale_a_1x():
|
||||
"""La conclusione 'liquidation fee 1% irrilevante' vale perche' il cap di live.json
|
||||
limita il nozionale lordo a <= 1x l'equity (2 asset x equity/2), con disaster-SL a -30%.
|
||||
Se il cap sale, quella conclusione NON vale piu': questo test deve rompersi."""
|
||||
cfg = json.loads((ROOT / "config" / "live.json").read_text())
|
||||
frac = cfg.get("max_notional_per_asset_frac")
|
||||
assert frac is not None, "cap dinamico rimosso: rivedere l'analisi liquidation fee"
|
||||
leva_max = frac * len(FS.ASSETS)
|
||||
assert leva_max <= 1.0, (
|
||||
f"leva massima {leva_max:.2f}x > 1x: la liquidation fee 1% torna rilevante, "
|
||||
"rivedere r0726_fee_sensitivity e il diario 2026-07-26-fee-deribit.md")
|
||||
assert cfg.get("disaster_sl_pct", 0) > 0, "disaster-SL disattivato: idem"
|
||||
# Qui viveva `test_leva_massima_da_config_resta_sotto_o_uguale_a_1x`: verificava che
|
||||
# `max_notional_per_asset_frac x n_asset <= 1.0`, a presidio della conclusione
|
||||
# "liquidation fee 1% irrilevante" (r0726_fee_sensitivity).
|
||||
#
|
||||
# E' stato CANCELLATO, non aggiustato, per la ragione registrata in CLAUDE.md §5.5:
|
||||
# misurava `frac x n_asset`, ma se il progetto introducesse la chiave di scala di
|
||||
# SPEC-scale-key la grandezza vera diventerebbe `frac x n_asset x scala` — e questo
|
||||
# test avrebbe CONTINUATO A PASSARE smettendo di controllare (P1: un sorvegliante che
|
||||
# non deriva il bersaglio dal codice sorvegliato passa sempre). Una guardia che passa
|
||||
# a bersaglio cambiato e' peggio di nessuna guardia: certifica una proprieta' che non
|
||||
# vale piu'.
|
||||
#
|
||||
# La guardia GIUSTA e' gia' scritta nella spec: GATE SCALA-01 impone il tetto sul
|
||||
# PRODOTTO `n_asset · frac · scala · disaster_sl_pct <= 0,50` — va cablata QUANDO (se)
|
||||
# la chiave di scala entrera' in config, dentro il codice che la legge, non qui.
|
||||
# Fino ad allora: la leva resta 1,0x per costruzione (nessuna chiave di scala esiste,
|
||||
# verificato in §3 di CLAUDE.md) e il disaster-SL e' presidiato dai test del book.
|
||||
|
||||
@@ -174,5 +174,66 @@ def test_i_monitor_reali_sono_leggibili_e_giudicati():
|
||||
# legato al REGISTRO, non a una costante: aggiungere un monitor e' normale (30/07:
|
||||
# collect_chain), dimenticarne uno per strada no.
|
||||
assert len(rows) == len(MONITORS) >= 6
|
||||
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
|
||||
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE", "PREMATURO")
|
||||
for r in rows)
|
||||
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PREMATURO — barra registrata prima della sua chiusura (difetto §5.1)
|
||||
# ===========================================================================
|
||||
# La freschezza e la copertura NON possono vederlo: una serie fresca, completa e SBAGLIATA le
|
||||
# passa entrambe (misurato: paper_statarb registrava 4 min/giorno con monitor_health "OK").
|
||||
# Controllo positivo in ENTRAMBI i versi, come raccomandato dall'audit.
|
||||
|
||||
def _spec_1d(**kw):
|
||||
from src.live.monitor_health import MonitorSpec
|
||||
return MonitorSpec("t", "t", "returns.jsonl", 24.0, **kw)
|
||||
|
||||
|
||||
def test_prematuro_si_accende_su_barra_scritta_prima_della_chiusura():
|
||||
from src.live.monitor_health import assess
|
||||
stamps = [NOW.replace(hour=0, minute=0) - timedelta(days=2),
|
||||
NOW.replace(hour=0, minute=0) - timedelta(days=1),
|
||||
NOW.replace(hour=0, minute=0)] # barra del giorno IN CORSO
|
||||
mtime = NOW.replace(hour=0, minute=35) # scritta alle 00:35 di oggi
|
||||
r = assess(_spec_1d(), stamps, NOW, mtime=mtime)
|
||||
assert r["status"] == "PREMATURO"
|
||||
assert "rigenerata" in r["why"]
|
||||
|
||||
|
||||
def test_prematuro_tace_su_una_serie_sana():
|
||||
from src.live.monitor_health import assess
|
||||
stamps = [NOW.replace(hour=0, minute=0) - timedelta(days=3),
|
||||
NOW.replace(hour=0, minute=0) - timedelta(days=2),
|
||||
NOW.replace(hour=0, minute=0) - timedelta(days=1)] # ultima barra = IERI, chiusa
|
||||
mtime = NOW.replace(hour=0, minute=35) # scritta stanotte alle 00:35
|
||||
r = assess(_spec_1d(), stamps, NOW, mtime=mtime)
|
||||
assert r["status"] == "OK"
|
||||
|
||||
|
||||
def test_prematuro_rispetta_la_grazia_di_clock():
|
||||
from src.live.monitor_health import assess
|
||||
# barra chiusa alle 00:00, file scritto alle 23:58 di IERI (orologio del feed avanti di 2'):
|
||||
# dentro la grazia di 5 min -> non e' un allarme
|
||||
stamps = [NOW.replace(hour=0, minute=0) - timedelta(days=2),
|
||||
NOW.replace(hour=0, minute=0) - timedelta(days=1)]
|
||||
mtime = NOW.replace(hour=0, minute=0) - timedelta(minutes=2)
|
||||
r = assess(_spec_1d(), stamps, NOW, mtime=mtime)
|
||||
assert r["status"] != "PREMATURO"
|
||||
|
||||
|
||||
def test_prematuro_non_giudica_i_timbri_di_esecuzione():
|
||||
from src.live.monitor_health import assess
|
||||
# collect_chain timbra l'ORA DEL GIRO, non un'apertura di barra: il timbro coincide con
|
||||
# la scrittura per costruzione e il controllo mentirebbe sempre (P14) -> open_labeled=False
|
||||
stamps = [NOW - timedelta(hours=2), NOW - timedelta(hours=1)]
|
||||
r = assess(_spec_1d(open_labeled=False), stamps, NOW, mtime=NOW - timedelta(hours=1))
|
||||
assert r["status"] != "PREMATURO"
|
||||
|
||||
|
||||
def test_prematuro_senza_mtime_non_inventa_un_verdetto():
|
||||
from src.live.monitor_health import assess
|
||||
stamps = [NOW.replace(hour=0, minute=0) - timedelta(days=1), NOW.replace(hour=0, minute=0)]
|
||||
r = assess(_spec_1d(), stamps, NOW, mtime=None)
|
||||
assert r["status"] != "PREMATURO" # senza mtime il controllo tace, non indovina
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""paper_guard: solo barre CHIUSE nei forward-monitor (riparazione del difetto §5.1).
|
||||
|
||||
Il difetto: `advance()` consumava la barra del giorno in corso e 4 monitor su 6 registravano
|
||||
minuti di mercato spacciati per giornate. La riparazione e' un filtro condiviso; qui si prova
|
||||
il filtro nei due versi, la sua indipendenza dalla risoluzione dei timestamp (D6 — pagata
|
||||
DI NUOVO alla prima stesura di questo stesso modulo), e che ogni monitor lo usi davvero.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.paper_guard import ( # noqa: E402
|
||||
MS_1D, MS_1H, chiusa, indice_chiuso, nuove_chiuse)
|
||||
|
||||
NOW = int(pd.Timestamp("2026-08-26T00:35:00Z").value // 10**6)
|
||||
OGGI = int(pd.Timestamp("2026-08-26T00:00:00Z").value // 10**6)
|
||||
IERI = OGGI - MS_1D
|
||||
|
||||
|
||||
def test_la_barra_del_giorno_in_corso_non_e_chiusa():
|
||||
assert chiusa(IERI, MS_1D, NOW)
|
||||
assert not chiusa(OGGI, MS_1D, NOW)
|
||||
|
||||
|
||||
def test_nuove_chiuse_salta_la_parziale_e_prende_la_chiusa():
|
||||
ts = [IERI - MS_1D, IERI, OGGI]
|
||||
assert nuove_chiuse(ts, IERI - MS_1D, MS_1D, NOW) == [1]
|
||||
# il giro successivo (dopo la mezzanotte seguente) la prende, una volta sola
|
||||
assert nuove_chiuse(ts, IERI, MS_1D, NOW + MS_1D) == [2]
|
||||
|
||||
|
||||
def test_nuove_chiuse_su_griglia_oraria():
|
||||
h0 = int(pd.Timestamp("2026-08-26T00:00:00Z").value // 10**6)
|
||||
ts = [h0 - 2 * MS_1H, h0 - MS_1H, h0] # l'ultima e' l'ora IN CORSO alle 00:35
|
||||
assert nuove_chiuse(ts, h0 - 3 * MS_1H, MS_1H, NOW) == [0, 1]
|
||||
|
||||
|
||||
def test_al_confine_esatto_la_barra_e_chiusa():
|
||||
# ts + cadenza == adesso: la barra e' finita in questo istante -> chiusa (<=, non <)
|
||||
assert chiusa(IERI, MS_1D, OGGI)
|
||||
|
||||
|
||||
def test_indice_chiuso_e_indipendente_dalla_risoluzione():
|
||||
"""D6: `asi8`/`view('int64')` cambiano scala con la risoluzione dell'indice (pandas 3 usa
|
||||
'us'), e un intero nella scala sbagliata passa senza eccezioni. La prima stesura di
|
||||
`indice_chiuso` e' caduta ESATTAMENTE qui: dava chiusa la barra del giorno in corso."""
|
||||
for unit in ("ns", "us", "s"):
|
||||
ix = pd.DatetimeIndex(
|
||||
np.array(["2026-08-25T00:00:00", "2026-08-26T00:00:00"], dtype=f"datetime64[{unit}]")
|
||||
).tz_localize("UTC")
|
||||
m = indice_chiuso(ix, MS_1D, NOW)
|
||||
assert list(m) == [True, False], (unit, list(m))
|
||||
|
||||
|
||||
def test_indice_naive_e_trattato_come_utc():
|
||||
ix = pd.DatetimeIndex(["2026-08-25", "2026-08-26"])
|
||||
assert list(indice_chiuso(ix, MS_1D, NOW)) == [True, False]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# guardia sul SORGENTE: ogni monitor deve usare il filtro, non ridichiararlo
|
||||
# ===========================================================================
|
||||
# Stessa forma della guardia su cron_daily.sh: il difetto e' rientrato nel progetto ogni volta
|
||||
# che una regola viveva solo nella memoria di chi l'aveva scritta (P1).
|
||||
|
||||
MONITOR_1D = ("paper_xsr", "paper_statarb", "paper_dvolspread")
|
||||
MONITOR_IDX = ("paper_portfolio", "paper_combo")
|
||||
|
||||
|
||||
def _src(nome: str) -> str:
|
||||
return (ROOT / "scripts" / "live" / f"{nome}.py").read_text()
|
||||
|
||||
|
||||
def test_i_monitor_1d_usano_nuove_chiuse():
|
||||
for nome in MONITOR_1D:
|
||||
s = _src(nome)
|
||||
assert "PG.nuove_chiuse(" in s, f"{nome}: advance() non filtra le barre chiuse"
|
||||
assert re.search(r"new = \[i for i in range\(len\(ts\)\) if ts\[i\] > st", s) is None, \
|
||||
f"{nome}: la vecchia riga che consumava la barra parziale e' ancora nel sorgente"
|
||||
|
||||
|
||||
def test_prevday_filtra_le_ore_chiuse():
|
||||
s = _src("paper_prevday")
|
||||
assert "PG.chiusa(" in s and "MS_1H" in s
|
||||
|
||||
|
||||
def test_i_monitor_a_indice_usano_indice_chiuso():
|
||||
for nome in MONITOR_IDX:
|
||||
assert "PG.indice_chiuso(" in _src(nome), f"{nome}: advance() non filtra i giorni chiusi"
|
||||
@@ -41,13 +41,19 @@ def test_advance_processes_forward_then_idempotent(tmp_path, monkeypatch):
|
||||
ts, *_ = ps._signal(j)
|
||||
st = _fresh_state(j, back=60)
|
||||
st = ps.advance(st, j)
|
||||
assert st["n_bars"] == 60 # ha processato le 60 barre forward
|
||||
assert st["last_ts"] == int(ts[-1])
|
||||
# ⚠️ 2026-08-26: l'attesa "== 60" codificava il DIFETTO §5.1 — l'ultima barra del
|
||||
# pannello e' il giorno IN CORSO e advance() ora la salta finche' non e' chiusa.
|
||||
# L'attesa giusta si DERIVA dal filtro di produzione, non si ridichiara (P1).
|
||||
from src.live.paper_guard import MS_1D, nuove_chiuse
|
||||
attese = len(nuove_chiuse(ts, int(ts[-61]), MS_1D))
|
||||
assert attese in (59, 60) # 60 solo se il pannello finisce su barra chiusa
|
||||
assert st["n_bars"] == attese
|
||||
assert st["last_ts"] == int(ts[attese - 61]) # la punta CHIUSA, non la punta del pannello
|
||||
assert st["cap_modeled"] != ps.MODELED_CAPITAL # l'equity si è mossa
|
||||
# idempotente: ri-avanzare alla punta non aggiunge barre né muove l'equity
|
||||
eq_before = st["cap_modeled"]
|
||||
st2 = ps.advance(dict(st), j)
|
||||
assert st2["n_bars"] == 60
|
||||
assert st2["n_bars"] == attese
|
||||
assert st2["cap_modeled"] == eq_before
|
||||
|
||||
|
||||
|
||||
+36
-6
@@ -9,6 +9,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -41,13 +42,42 @@ def test_t1_le_modalita_condividono_le_stesse_trade_boundaries():
|
||||
|
||||
def test_t1_canonical_riproduce_il_backtest_ufficiale():
|
||||
"""La modalita' 'canonical' deve coincidere col backtest dello sleeve: e' l'ancora
|
||||
di tutto il confronto. Tolleranza larga (il feed cresce ogni giorno)."""
|
||||
di tutto il confronto.
|
||||
|
||||
⚠️ RISCRITTO il 2026-08-26 (era il rosso noto §5.9, indagato PRIMA di toccarlo —
|
||||
`r0826_skh_band_drift.py`). La versione precedente confrontava una banda scritta
|
||||
sulla finestra del 02/07 con un numero calcolato sul feed VIVO, che il cron allunga
|
||||
ogni notte: l'hold-out e' passato da 1,64 a 1,93 senza che nulla si fosse rotto —
|
||||
e la SOLA settimana 15-22/08 (rally ETH con SKH01 long) vale +0,35 di Sharpe
|
||||
hold-out. Una banda che insegue il feed non riproduce niente: qui il feed viene
|
||||
TAGLIATO alla data dell'audit, cosi' il test torna a essere una riproduzione
|
||||
(N11) — si rompe se cambiano il CODICE o il DATO sulla finestra congelata, e per
|
||||
nessun altro motivo. La deriva del vivo non e' piu' affare suo: per quella ci sono
|
||||
i monitor."""
|
||||
import r0702_anchor_skh01 as r02
|
||||
import r0726_skh_onbook as ob
|
||||
s, _ = ob.skh_series(0, "canonical")
|
||||
full, _, hold, dd = ob.sh3(s)
|
||||
assert 1.2 < full < 1.7, full # audit 02/07: ~1.46
|
||||
assert 1.3 < hold < 1.9, hold # audit 02/07: ~1.64
|
||||
assert 0.12 < dd < 0.24, dd # audit 02/07: ~18.1%
|
||||
CUT = int(pd.Timestamp("2026-07-02", tz="UTC").value // 10**6)
|
||||
get5m_vero = r02.get5m
|
||||
|
||||
def _tagliato(asset: str):
|
||||
df = get5m_vero(asset)
|
||||
return df[df["timestamp"] < CUT].reset_index(drop=True)
|
||||
|
||||
r02.get5m = _tagliato
|
||||
r02._CACHE.clear() # run_asset memoizza in un dict, non in lru_cache
|
||||
r02.skh_port.cache_clear()
|
||||
try:
|
||||
s, _ = ob.skh_series(0, "canonical")
|
||||
full, ins, hold, dd = ob.sh3(s)
|
||||
finally:
|
||||
r02.get5m = get5m_vero
|
||||
r02._CACHE.clear() # le cache tagliate non devono avvelenare altri test
|
||||
r02.skh_port.cache_clear()
|
||||
# i numeri misurati da r0826_skh_band_drift al taglio 02/07 (== audit 02/07)
|
||||
assert abs(full - 1.463) < 0.02, full
|
||||
assert abs(ins - 1.424) < 0.02, ins
|
||||
assert abs(hold - 1.6376) < 0.02, hold
|
||||
assert abs(dd - 0.181) < 0.01, dd
|
||||
|
||||
|
||||
def test_t1_il_limit_tp_non_puo_riempire_peggio_del_livello():
|
||||
|
||||
Reference in New Issue
Block a user