giornale: lettura ragionata a regole dichiarate e tracciabili
Non prosa libera: dodici regole, ognuna con un id stampato accanto alla riga che ha prodotto. Combinano solo numeri gia' presenti nella pagina, tacciono sul non misurato e non prevedono niente. Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano. Le regole: stato del libro e PERCHE' e' flat (componente per componente), disaccordo fra orizzonti del trend contro esposizione TP01, incoerenza (trend su ma libro fuori), leva contro il tetto, P&L del giorno, costo che supera il movimento, concentrazione del cumulato, drawdown dal picco, implicita contro realizzata col gate IV-rank di VRP01, giornata oltre 2 deviazioni, giri mancanti e feed vecchio, e la taglia del campione. Ogni regola ha DUE test: uno che la accende e uno che la tiene spenta. Una regola che si accende sempre non sta leggendo niente, una che non si accende mai e' indistinguibile da una rotta. Tre difetti corretti prima di pubblicare, tutti trovati scrivendo i test: - il tetto di leva era RIDICHIARATO (0.5 cablato) invece che letto da config/live.json — quinta occorrenza dello schema che il progetto paga da luglio: un sorvegliante che ridichiara il proprio bersaglio continua a passare il giorno che il bersaglio cambia. Ora deriva, e se il config non si legge lo dice invece di inventare un tetto. - l'IV-rank era il percentile a UN ANNO etichettato col nome del gate di VRP01, che usa un percentile ESPANDENTE. Due statistiche diverse, e oggi danno il verdetto OPPOSTO: 0.52 (sopra la soglia, "il sleeve venderebbe") contro 0.18 (sotto, sleeve fermo). Il valore giusto combacia con quanto gia' misurato il 30/07: 0/8 settimane passano il gate. - la concentrazione si accendeva su $2,08 di cumulato: vera e inutile. Ora ha una soglia di rilevanza, o e' una riga che insegna a saltare la sezione. 62 voci rigenerate. Strategia, pesi, config INVARIATI. 675 test passano. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,3 +136,174 @@ def test_lo_stato_tsmom_usa_gli_orizzonti_di_produzione():
|
||||
src = Path(tp.__file__).read_text()
|
||||
for n in J.ORIZZONTI:
|
||||
assert str(n) in src, f"orizzonte {n} non compare in trend_portfolio.py"
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# LETTURA RAGIONATA — ogni regola ha un test che la ACCENDE e uno che la tiene SPENTA
|
||||
# =============================================================================================
|
||||
# Una regola che si accende sempre non sta leggendo niente, e una che non si accende mai e'
|
||||
# indistinguibile da una rotta. Servono entrambe le direzioni.
|
||||
|
||||
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 test_libro_flat_dice_anche_PERCHE(db):
|
||||
v = _voce(libro=_libro(BTC={}, ETH={}), pnl=_pnl())
|
||||
r = J.lettura(db, v)
|
||||
assert "stato" in _ids(r) and "perche_flat" in _ids(r)
|
||||
testo = next(x["testo"] for x in r if x["id"] == "perche_flat")
|
||||
assert "TP01 a zero" in testo and "SKH01 senza breakout" in testo
|
||||
|
||||
|
||||
def test_libro_a_mercato_non_spiega_un_flat_che_non_c_e(db):
|
||||
v = _voce(libro=_libro(BTC=dict(tp_frac=0.15, skh_sign=1, posizione=116.0)), pnl=_pnl())
|
||||
r = J.lettura(db, v)
|
||||
assert "perche_flat" not in _ids(r)
|
||||
assert "$+116" in next(x["testo"] for x in r if x["id"] == "stato")
|
||||
|
||||
|
||||
def test_disaccordo_si_accende_a_2_su_3_e_tace_a_3_su_3(db):
|
||||
lb = _libro(BTC=dict(tp_frac=0.15, skh_sign=1, posizione=116.0))
|
||||
misto = {"BTC": dict(chiusura=77000.0, tsmom_su=(2, 3), tsmom={})}
|
||||
pieno = {"BTC": dict(chiusura=77000.0, tsmom_su=(3, 3), tsmom={})}
|
||||
assert "disaccordo" in _ids(J.lettura(db, _voce(mercato=misto, libro=lb, pnl=_pnl())))
|
||||
assert "disaccordo" not in _ids(J.lettura(db, _voce(mercato=pieno, libro=lb, pnl=_pnl())))
|
||||
|
||||
|
||||
def test_incoerenza_trend_su_ma_libro_fuori(db):
|
||||
lb = _libro(BTC=dict(tp_frac=0.0, skh_sign=0, posizione=0.0))
|
||||
m = {"BTC": dict(chiusura=77000.0, tsmom_su=(3, 3), tsmom={})}
|
||||
r = J.lettura(db, _voce(mercato=m, libro=lb, pnl=_pnl()))
|
||||
riga = next(x for x in r if x["id"] == "incoerenza")
|
||||
assert riga["livello"] == "attenzione"
|
||||
|
||||
|
||||
def test_il_tetto_di_leva_e_DERIVATO_dal_config_non_riscritto(db, monkeypatch, tmp_path):
|
||||
"""Se qualcuno cambia `max_notional_per_asset_frac`, la lettura deve cambiare da sola."""
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "config" / "live.json").write_text(json.dumps({"max_notional_per_asset_frac": 0.25}))
|
||||
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path)
|
||||
lb = _libro(BTC=dict(posizione=150.0), ETH=dict(posizione=150.0))
|
||||
r = J.lettura(db, _voce(libro=lb, pnl=_pnl()))
|
||||
testo = next(x["testo"] for x in r if x["id"] == "leva")
|
||||
assert "0.50x" in testo and "0.25" in testo # 0.25 x 2 asset, NON 1.00x
|
||||
|
||||
|
||||
def test_config_illeggibile_lo_dice_invece_di_inventare_un_tetto(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path) # nessun config qui
|
||||
r = J.lettura(db, _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl()))
|
||||
riga = next(x for x in r if x["id"] == "leva")
|
||||
assert "non verificabile" in riga["testo"] and riga["livello"] == "attenzione"
|
||||
|
||||
|
||||
def test_soglia_IV_rank_derivata_dal_sleeve(db):
|
||||
"""La soglia del gate viene da VRP_CFG, e l'IV-rank e' quello ESPANDENTE del sleeve."""
|
||||
from src.portfolio.sleeves import VRP_CFG
|
||||
lb = _libro(BTC=dict(posizione=100.0))
|
||||
m = {"BTC": dict(chiusura=77000.0, dvol=42.0, rv30_annua=43.0, dvol_rank_exp=0.18)}
|
||||
testo = next(x["testo"] for x in J.lettura(db, _voce(mercato=m, libro=lb, pnl=_pnl()))
|
||||
if x["id"] == "vol")
|
||||
assert f"{VRP_CFG['gate_ivr']:.2f}" in testo and "sotto la soglia" in testo
|
||||
m["BTC"]["dvol_rank_exp"] = 0.90
|
||||
testo = next(x["testo"] for x in J.lettura(db, _voce(mercato=m, libro=lb, pnl=_pnl()))
|
||||
if x["id"] == "vol")
|
||||
assert "**sopra**" in testo
|
||||
|
||||
|
||||
def test_iv_rank_espandente_e_diverso_dal_rank_a_un_anno():
|
||||
"""I due rank rispondono a domande diverse: il 2026-08-23 danno 0.18 contro 0.52.
|
||||
|
||||
Etichettare l'uno col nome dell'altro ribalta il verdetto sul gate di VRP01.
|
||||
"""
|
||||
m = J.metriche_mercato(date.today())
|
||||
for a in ("BTC", "ETH"):
|
||||
d = m.get(a) or {}
|
||||
if d.get("dvol_rank_exp") is None or d.get("dvol_rank_1a") is None:
|
||||
continue
|
||||
assert d["dvol_rank_exp"] != d["dvol_rank_1a"]
|
||||
|
||||
|
||||
def test_regola_fee_quando_il_costo_supera_il_movimento(db):
|
||||
lb = _libro(BTC=dict(posizione=100.0))
|
||||
r = J.lettura(db, _voce(libro=lb, pnl=_pnl(delta_equity=0.01, fill=2, fee=0.05)))
|
||||
assert "fee" in _ids(r)
|
||||
r = J.lettura(db, _voce(libro=lb, pnl=_pnl(delta_equity=5.00, fill=2, fee=0.05)))
|
||||
assert "fee" not in _ids(r) and "pnl" in _ids(r)
|
||||
|
||||
|
||||
def test_evento_si_accende_oltre_2_deviazioni_e_tace_sotto(db):
|
||||
lb = _libro(BTC=dict(posizione=100.0))
|
||||
# RV30 36.5% annua -> sd giornaliera ~1.91%
|
||||
grosso = {"BTC": dict(chiusura=77000.0, ret_1g=-6.0, rv30_annua=36.5)}
|
||||
normale = {"BTC": dict(chiusura=77000.0, ret_1g=-1.0, rv30_annua=36.5)}
|
||||
assert "evento" in _ids(J.lettura(db, _voce(mercato=grosso, libro=lb, pnl=_pnl())))
|
||||
assert "evento" not in _ids(J.lettura(db, _voce(mercato=normale, libro=lb, pnl=_pnl())))
|
||||
|
||||
|
||||
def test_le_regole_tacciono_sul_non_misurato(db):
|
||||
"""Mercato assente: nessuna riga su vol o eventi. Il silenzio e' il comportamento giusto."""
|
||||
r = J.lettura(db, _voce(mercato={"BTC": {"errore": "feed KO"}},
|
||||
libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(delta_equity=None)))
|
||||
assert "vol" not in _ids(r) and "evento" not in _ids(r) and "pnl" not in _ids(r)
|
||||
|
||||
|
||||
def test_giri_mancanti_e_feed_vecchio_sono_attenzioni(db):
|
||||
s = dict(giri_book=6, feed_skh_min=685, atteso_giri=24, giorno_in_corso=False, giri_mancanti=18)
|
||||
r = J.lettura(db, _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(), salute=s))
|
||||
liv = {x["id"]: x["livello"] for x in r}
|
||||
assert liv.get("giri") == "attenzione" and liv.get("feed") == "attenzione"
|
||||
|
||||
|
||||
def test_senza_giri_la_lettura_dice_NON_MISURATO_e_si_ferma(db):
|
||||
r = J.lettura(db, _voce())
|
||||
assert _ids(r) == ["stato"] and "non e' stato misurato" in r[0]["testo"]
|
||||
|
||||
|
||||
def test_il_bucket_di_convinzione_non_inventa_i_due_terzi():
|
||||
"""`tsmom_blend` media tre np.sign(): dopo il clip long-flat la direzione sta in {0,1/3,1}."""
|
||||
assert J._bucket_convinzione(0.0) == "flat"
|
||||
assert J._bucket_convinzione(0.15) == "parziale"
|
||||
assert J._bucket_convinzione(0.90) == "piena"
|
||||
|
||||
|
||||
def test_concentrazione_tace_su_un_cumulato_irrilevante(db):
|
||||
"""Vera ma inutile: su pochi dollari la concentrazione e' aritmetica, non informazione."""
|
||||
for i in range(60):
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)",
|
||||
(f"2026-06-{(i % 28) + 1:02d}T{i % 24:02d}:00:00+00:00", 600.0 + i * 0.03, "t"))
|
||||
db.commit()
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(), giorno="2026-06-28")
|
||||
assert "concentrazione" not in _ids(J.lettura(db, v)) # ~$1.8 di cumulato
|
||||
|
||||
|
||||
def test_concentrazione_si_accende_quando_la_taglia_conta(db):
|
||||
for i in range(60):
|
||||
v = 600.0 if i < 50 else 600.0 + (i - 49) * 4.0 # salto nella coda
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)",
|
||||
(f"2026-06-{(i % 28) + 1:02d}T{i % 24:02d}:00:00+00:00", v, "t"))
|
||||
db.commit()
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(), giorno="2026-06-28")
|
||||
assert "concentrazione" in _ids(J.lettura(db, v))
|
||||
|
||||
Reference in New Issue
Block a user