research(capitale): il lump-sum vale 2.45x, e la protezione dalla rovina non passa da GTAA01
Quattro filoni chiesti dall'operatore ("proposte"). Book, pesi, config: INVARIATI.
1. LUMP-SUM + VENUE (r0727_lumpsum_split.py). Tutte le traiettorie del 25-26/07 avevano
START=600 cablato: mai misurato un versamento iniziale, mentre ~10k EUR stanno fermi
altrove. Macchineria validata: con lump 0 riproduce IDENTICI i numeri del 26/07.
- 10k EUR oggi e mai piu' nulla -> traguardo 17.2a, P 62%, rendita 61.58 EUR/g
- equivalenza onesta: +154 EUR/mese per 13 anni = 24.523 EUR, cioe' 2.45x
(la prima stesura misurava i versamenti risparmiati: numero giusto, domanda sbagliata)
- col rischio venue: a 11.500$ lo split e' possibile (quota IB 26%, non 25%) e taglia
P(perso tutto) da 18.4% a 3.5% a p=1%, costando 1.9-2.6pp di P(arrivare)
- SPLIT-CASSA: seconda gamba ferma costa altri 0.6-0.8pp e protegge IDENTICO
-> la protezione non e' bloccata dal PRIIPs: serve un CONTO, non uno sleeve
2. FEE WATCH (scripts/live/fee_watch.py). Nuovo schema Deribit dal 1 agosto senza numeri
pubblicati -> sorvegliante invece di promemoria. Legge il tier base dall'endpoint
pubblico (oggi taker 5.00 bps), applica la regola congelata e allerta sui cambiamenti.
3. MONITOR HEALTH (src/live/monitor_health.py). Tre gate pre-registrati si decidono su
serie forward di cui una sola era sorvegliata. Misura coda E buchi interni: una serie
bucata ma fresca passa qualunque guardia di freschezza.
4. BANDA GTAA01 25% VALIDATA (r0727_gtaa_band_gate.py). 30 celle, 29.9 anni, dpy=252.
Non e' selection-on-holdout (4/30 IS, 5/30 OOS), DSR 0.999, tracking OK ma AL BORDO.
Il modo di fallire non e' il de-levering (la vol non scende) ma la perdita di tracking.
Impatto sul book: zero -> REBAL_BAND_USD non toccato, si applica al deploy.
Aggiunto anche il bullet edge_watch, cablato il 26/07 e mai finito in CLAUDE.md.
Test: 56 nuovi, 504/504 verdi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Test della sorveglianza fee (scripts/live/fee_watch.py).
|
||||
|
||||
Il nuovo schema fee Deribit entra in vigore il 2026-08-01 SENZA numeri pubblicati da fonte
|
||||
primaria. La regola e' stata decisa PRIMA di vedere il numero (r0726_fee_sensitivity.py):
|
||||
taker <=5bps -> nulla, >10bps -> rivedere il peso di SKH01. Questi test congelano la regola e
|
||||
la sua taratura, cosi' che il numero non possa muovere la soglia una volta arrivato.
|
||||
|
||||
Il test piu' importante e' `test_baseline_e_quella_dei_backtest`: lega la soglia della
|
||||
sorveglianza alla fee usata nei backtest. Se qualcuno cambia una delle due senza l'altra, il
|
||||
confronto smette di avere senso e il test lo dice.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
import fee_watch as FW # noqa: E402
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# la regola congelata
|
||||
# ===========================================================================
|
||||
def test_al_tier_di_oggi_non_si_tocca_nulla():
|
||||
assert FW.verdict(5.0)[0] == "OK"
|
||||
|
||||
|
||||
def test_sotto_il_tier_di_oggi_i_backtest_restano_conservativi():
|
||||
"""Se la fee SCENDE i numeri pubblicati non vanno rifatti: sono un limite inferiore."""
|
||||
assert FW.verdict(2.0)[0] == "OK"
|
||||
|
||||
|
||||
def test_fra_5_e_10_bps_si_riporta_il_costo_ma_non_si_agisce():
|
||||
assert FW.verdict(5.1)[0] == "NOTA"
|
||||
assert FW.verdict(10.0)[0] == "NOTA"
|
||||
|
||||
|
||||
def test_sopra_10_bps_scatta_la_revisione_del_peso_di_skh01():
|
||||
lvl, why = FW.verdict(10.1)
|
||||
assert lvl == "AZIONE"
|
||||
assert "SKH01" in why
|
||||
|
||||
|
||||
def test_le_soglie_sono_quelle_dichiarate_in_claude_md():
|
||||
"""Una regola decisa in anticipo vale solo se non la si puo' spostare dopo aver visto il
|
||||
numero: qui le due soglie sono congelate."""
|
||||
assert (FW.SOGLIA_OK, FW.SOGLIA_AZIONE) == (5.0, 10.0)
|
||||
|
||||
|
||||
def test_baseline_e_quella_dei_backtest():
|
||||
"""5 bps/lato = 0.10% RT = il default di backtest_signals. Le due devono restare agganciate."""
|
||||
from src.backtest.harness import backtest_signals
|
||||
import inspect
|
||||
default_fee_rt = inspect.signature(backtest_signals).parameters["fee_rt"].default
|
||||
assert FW.BASELINE_TAKER_BPS * 2 / 1e4 == default_fee_rt
|
||||
|
||||
|
||||
def test_sensibilita_marginale_e_quella_misurata():
|
||||
"""-0.017 Sharpe/bps di book (r0726_fee_sensitivity). Se cambia la misura, cambia qui."""
|
||||
assert FW.D_SHARPE_PER_BPS == -0.017
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# rilevazione del cambiamento — il motivo per cui e' un cron e non un promemoria
|
||||
# ===========================================================================
|
||||
def test_prima_lettura_non_e_un_evento():
|
||||
"""Senza stato precedente non c'e' un cambiamento: c'e' un'inizializzazione. Allertare
|
||||
qui vorrebbe dire allertare a ogni installazione."""
|
||||
assert FW.diff_vs({}, {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}) == []
|
||||
|
||||
|
||||
def test_nessun_cambiamento_nessun_rumore():
|
||||
cur = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}
|
||||
assert FW.diff_vs(dict(cur), cur) == []
|
||||
|
||||
|
||||
def test_un_taker_che_sale_viene_visto():
|
||||
prev = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}
|
||||
cur = {"BTC-PERPETUAL": dict(taker_bps=8.0, maker_bps=0.0, liq_bps=75.0)}
|
||||
ch = FW.diff_vs(prev, cur)
|
||||
assert len(ch) == 1 and "taker" in ch[0] and "8.00" in ch[0]
|
||||
|
||||
|
||||
def test_anche_maker_e_liquidazione_sono_sorvegliati():
|
||||
"""L'annuncio tocca tre cose: taker piu' bassi, rebate maker piu' bassi, liquidation fee 1%.
|
||||
Sorvegliarne una sola vorrebbe dire non accorgersi delle altre due."""
|
||||
prev = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=90.0)}
|
||||
cur = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=1.0, liq_bps=100.0)}
|
||||
ch = FW.diff_vs(prev, cur)
|
||||
assert len(ch) == 2
|
||||
assert any("maker" in c for c in ch) and any("liquidazione" in c for c in ch)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# assunzione dichiarata
|
||||
# ===========================================================================
|
||||
def test_sorveglia_il_tier_base_e_lo_dichiara():
|
||||
"""A $600 il volume 30g e' trascurabile: il conto sta al tier BASE, che e' cio' che
|
||||
l'endpoint pubblico riporta. Se un giorno il conto salisse di tier, questo script
|
||||
misurerebbe la cosa sbagliata — l'assunzione sta nel docstring, e questo test la lega."""
|
||||
assert "public/get_instrument" in FW.API or "get_instrument" in FW.API
|
||||
assert "tier BASE" in FW.__doc__ or "tier base" in FW.__doc__
|
||||
|
||||
|
||||
def test_fee_reali_assenti_non_sono_fee_zero():
|
||||
"""{} = 'non misurata'. Un dict vuoto letto come 'zero' direbbe che il book trada gratis."""
|
||||
assert FW.realized_fee_bps.__doc__ and "non misurata" in FW.realized_fee_bps.__doc__
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Test del gate onesto sulla banda GTAA01 (scripts/research/r0727_gtaa_band_gate.py).
|
||||
|
||||
Congela i tre esiti del gate e — soprattutto — l'ANNUALIZZAZIONE. Una serie su giorni di borsa
|
||||
passata a un annualizzatore a 365 esce con lo Sharpe ×1.20 e il CAGR ×1.45: e' l'errore del
|
||||
25/07, ed e' l'unico difetto qui che produrrebbe numeri sbagliati ma plausibili.
|
||||
|
||||
`test_la_proposta_non_e_selezionata_sull_hold_out` e' il test di metodo: se un giorno la cella
|
||||
proposta risultasse in rango MIGLIORE sull'hold-out che in-sample, sarebbe la firma della
|
||||
selezione-sull'hold-out e la validazione andrebbe rifatta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import r0727_gtaa_band_gate as BG # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def grid():
|
||||
"""Griglia ridotta alla sola cadenza della proposta: basta per i gate (B) e (C) e tiene
|
||||
il test veloce. La griglia piena vive nello script."""
|
||||
ref = BG.run_cell(1, 0.0, BG.CAP_REF)
|
||||
out = {}
|
||||
for frac in BG.FRAC_GRID:
|
||||
s = BG.run_cell(BG.PROPOSTA[0], frac, BG.CAP_REF)
|
||||
j = pd.concat([s, ref], axis=1, join="inner").dropna()
|
||||
out[frac] = dict(series=s, met=BG.met(s),
|
||||
corr=float(j.iloc[:, 0].corr(j.iloc[:, 1])))
|
||||
return ref, out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# annualizzazione — l'errore che produce numeri plausibili e sbagliati
|
||||
# ===========================================================================
|
||||
def test_annualizza_a_giorni_di_borsa():
|
||||
assert BG.DPY == 252.0
|
||||
|
||||
|
||||
def test_lo_sharpe_e_calcolato_a_252_non_a_365():
|
||||
idx = pd.bdate_range("2020-01-01", periods=1000)
|
||||
r = pd.Series(np.linspace(0.0004, 0.0006, 1000), index=idx)
|
||||
v = r.values
|
||||
atteso = v.mean() / v.std() * np.sqrt(252.0) # std di popolazione, come nel resto del progetto
|
||||
assert BG.met(r)["sharpe"] == pytest.approx(atteso, rel=1e-12)
|
||||
assert BG.met(r)["sharpe"] != pytest.approx(v.mean() / v.std() * np.sqrt(365.0), rel=1e-6)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# la parametrizzazione: e' questa la proposta
|
||||
# ===========================================================================
|
||||
def test_la_banda_e_una_frazione_della_gamba_non_un_importo():
|
||||
"""E' l'intera ragione della proposta: un importo assoluto ha effetti diversi a capitali
|
||||
diversi, una frazione no."""
|
||||
from src.portfolio.gtaa import EQ_UNIVERSE
|
||||
for cap in (3_000.0, 10_000.0, 50_000.0):
|
||||
assert 0.25 * cap / len(EQ_UNIVERSE) == pytest.approx(0.25 * cap / 6)
|
||||
|
||||
|
||||
def test_la_produzione_usa_ancora_la_banda_ASSOLUTA():
|
||||
"""Il gate valida una proposta che NON e' ancora in produzione. Se un giorno la produzione
|
||||
passasse alla banda frazionaria, questo test si rompe e va riletto tutto il confronto."""
|
||||
from src.portfolio.gtaa import REBAL_BAND_USD, REBAL_EVERY
|
||||
assert (REBAL_EVERY, REBAL_BAND_USD) == BG.CANONICO == (5, 50.0)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (A) selezione — il gate di metodo
|
||||
# ===========================================================================
|
||||
def test_la_proposta_non_e_selezionata_sull_hold_out():
|
||||
"""Rango in-sample <= rango hold-out: la proposta non migliora passando all'hold-out.
|
||||
Il contrario sarebbe la firma della selezione-sull'hold-out."""
|
||||
rows = []
|
||||
for every in BG.EVERY_GRID:
|
||||
for frac in BG.FRAC_GRID:
|
||||
s = BG.run_cell(every, frac, BG.CAP_REF)
|
||||
rows.append(dict(every=every, frac=frac,
|
||||
sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
|
||||
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"]))
|
||||
G = pd.DataFrame(rows)
|
||||
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
|
||||
rank_in = int((G["sh_in"] > p["sh_in"]).sum()) + 1
|
||||
rank_oos = int((G["sh_oos"] > p["sh_oos"]).sum()) + 1
|
||||
assert rank_in <= rank_oos, (
|
||||
f"la proposta e' {rank_oos}a sull'hold-out ma {rank_in}a in-sample: "
|
||||
"sta meglio dove non doveva essere guardata")
|
||||
|
||||
|
||||
def test_l_hold_out_usato_e_quello_documentato_di_gtaa01():
|
||||
"""2015+: l'OOS equity della validazione 22/06, indipendente dall'hold-out crypto."""
|
||||
assert BG.HOLDOUT == "2015-01-01"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (B) deflated Sharpe
|
||||
# ===========================================================================
|
||||
def test_la_proposta_sopravvive_alla_deflazione(grid):
|
||||
from altlib import deflated_sharpe
|
||||
ref, cells = grid
|
||||
all_sr = [c["met"]["sharpe"] for c in cells.values()]
|
||||
s = cells[BG.PROPOSTA[1]]["series"]
|
||||
dsr, _ = deflated_sharpe(BG.met(s)["sharpe"], all_sr, s.dropna().values, dpy=BG.DPY)
|
||||
assert dsr >= 0.95
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (C) tracking — e il modo di fallire, che NON e' il de-levering
|
||||
# ===========================================================================
|
||||
def test_al_25_percento_lo_sleeve_segue_ancora_se_stesso(grid):
|
||||
_, cells = grid
|
||||
assert cells[0.25]["corr"] >= BG.MIN_CORR
|
||||
|
||||
|
||||
def test_oltre_il_25_percento_il_tracking_si_rompe(grid):
|
||||
_, cells = grid
|
||||
assert cells[0.40]["corr"] < BG.MIN_CORR
|
||||
assert cells[0.60]["corr"] < cells[0.40]["corr"]
|
||||
|
||||
|
||||
def test_la_banda_larga_non_de_leva(grid):
|
||||
"""Il null de-levering classico NON morde qui: allargando la banda la vol non scende.
|
||||
Se un giorno scendesse, il fallimento sarebbe di un altro tipo e il verdetto va riscritto."""
|
||||
ref, cells = grid
|
||||
vref = BG.met(ref)["vol"]
|
||||
assert cells[0.60]["met"]["vol"] / vref > 0.95
|
||||
|
||||
|
||||
def test_il_25_percento_e_al_bordo_non_al_centro(grid):
|
||||
"""Onesta' sul margine: la proposta passa, ma di poco. Congelarlo evita che in futuro si
|
||||
citi come un plateau largo."""
|
||||
_, cells = grid
|
||||
assert BG.MIN_CORR <= cells[0.25]["corr"] < BG.MIN_CORR + 0.02
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# perche' la proposta esiste: invarianza al capitale
|
||||
# ===========================================================================
|
||||
def test_la_banda_frazionaria_batte_quella_fissa_a_capitale_piccolo():
|
||||
from src.portfolio.gtaa import gtaa_returns
|
||||
cap = 3_000.0
|
||||
frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"]
|
||||
fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"]
|
||||
assert frazionaria > fissa
|
||||
|
||||
|
||||
def test_a_capitale_grande_le_due_configurazioni_convergono():
|
||||
"""A $50k la banda fissa smette di essere una tassa: il vantaggio della proposta e' al
|
||||
capitale PICCOLO, ed e' li' che va citato."""
|
||||
from src.portfolio.gtaa import gtaa_returns
|
||||
cap = 50_000.0
|
||||
frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"]
|
||||
fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"]
|
||||
assert abs(frazionaria - fissa) < abs(
|
||||
BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], 3_000.0))["sharpe"]
|
||||
- BG.met(gtaa_returns(capital=3_000.0, band_usd=50.0, every=5))["sharpe"])
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Test del filone lump-sum + split di venue (scripts/research/r0727_lumpsum_split.py).
|
||||
|
||||
Il test piu' importante e' `test_riproduce_esattamente_i_numeri_del_26_07`: la macchineria e' una
|
||||
GENERALIZZAZIONE di `r0726_venue_risk.simulate` (capitale iniziale e deposito diventano
|
||||
parametri). Se la generalizzazione avesse cambiato anche solo l'ordine di consumo dell'RNG, i
|
||||
numeri pubblicati e quelli nuovi non sarebbero piu' confrontabili — e la tabella nuova
|
||||
sembrerebbe comunque plausibile.
|
||||
|
||||
Il secondo che conta e' `test_sotto_la_soglia_lo_split_non_esiste`: uno 'split' in cui la seconda
|
||||
gamba e' sotto il minimo eseguibile non e' uno split, e' cash su un secondo conto — che protegge
|
||||
dal fallimento del venue ma NON e' lo sleeve che i numeri assumono.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0727_lumpsum_split as LS # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def series():
|
||||
"""Serie sintetiche a drift noto: i test sulla struttura non devono dipendere dal feed."""
|
||||
idx = pd.date_range("2020-01-01", periods=1200, freq="D", tz="UTC")
|
||||
rng = np.random.default_rng(7)
|
||||
return {
|
||||
"Deribit": pd.Series(rng.normal(0.0005, 0.008, len(idx)), index=idx),
|
||||
"IB": pd.Series(rng.normal(0.0002, 0.004, len(idx)), index=idx),
|
||||
"Hyperliquid": pd.Series(rng.normal(0.0003, 0.010, len(idx)), index=idx),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# la replica — senza questa, la tabella nuova non e' confrontabile con quella vecchia
|
||||
# ===========================================================================
|
||||
def test_riproduce_esattamente_i_numeri_del_26_07(series):
|
||||
import r0726_venue_risk as VR
|
||||
cfg = {"Deribit": 1.0}
|
||||
kw = dict(n_paths=200, years=3, seed=LS.SEED)
|
||||
mine = LS.simulate(cfg, series, 0.01, lump_usd=0.0, dep_eur=VR.DEP_EUR, **kw)
|
||||
theirs = VR.simulate(cfg, series, 0.01, **kw)
|
||||
assert mine["p_reach"] == theirs["p_reach"]
|
||||
assert mine["p_all_dead"] == theirs["p_all_dead"]
|
||||
assert mine["median_end"] == theirs["median_end"]
|
||||
|
||||
|
||||
def test_il_deposito_di_default_e_quello_delle_traiettorie_pubblicate(series):
|
||||
"""€250/mese: la colonna con cui si confronta tutto il piano."""
|
||||
import inspect
|
||||
import r0726_venue_risk as VR
|
||||
assert inspect.signature(LS.simulate).parameters["dep_eur"].default == VR.DEP_EUR
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# il lump-sum
|
||||
# ===========================================================================
|
||||
def test_un_lump_sum_non_puo_peggiorare_il_traguardo(series):
|
||||
kw = dict(n_paths=300, years=5, seed=1)
|
||||
senza = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, **kw)
|
||||
con = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=10_000.0, **kw)
|
||||
assert con["p_reach"] >= senza["p_reach"]
|
||||
assert con["median_end"] > senza["median_end"]
|
||||
|
||||
|
||||
def test_con_un_conto_solo_la_rovina_non_dipende_dal_capitale(series):
|
||||
"""Fatto strutturale, non aneddoto: con UN venue 'almeno un fallimento' COINCIDE con
|
||||
'perso tutto', e quella probabilita' e' una proprieta' del TEMPO di esposizione. Se un
|
||||
giorno questa uguaglianza si rompesse, il modello di rovina e' cambiato."""
|
||||
kw = dict(n_paths=400, years=5, seed=3)
|
||||
a = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=0.0, **kw)
|
||||
b = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=20_000.0, **kw)
|
||||
assert a["p_all_dead"] == b["p_all_dead"]
|
||||
|
||||
|
||||
def test_senza_deposito_e_senza_lump_non_si_arriva(series):
|
||||
r = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, dep_eur=0.0,
|
||||
n_paths=200, years=5, seed=2)
|
||||
assert r["p_reach"] == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# lo split, e la soglia che lo rende reale
|
||||
# ===========================================================================
|
||||
def test_sotto_la_soglia_lo_split_non_esiste():
|
||||
"""A $6.050 servirebbe il 50% su IB per fare $3.000: oltre il tetto, quindi None.
|
||||
Dichiararlo 'possibile' vorrebbe dire modellare uno sleeve che non e' eseguibile."""
|
||||
assert LS.split_config(600.0) is None
|
||||
assert LS.split_config(6_050.0) is None
|
||||
|
||||
|
||||
def test_sopra_la_soglia_e_il_capitale_a_scegliere_la_quota():
|
||||
cfg = LS.split_config(11_500.0)
|
||||
assert cfg is not None
|
||||
assert cfg["IB"] == pytest.approx(3_000.0 / 11_500.0, rel=1e-9) # ~26%, non il 25% preferito
|
||||
assert cfg["Deribit"] + cfg["IB"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_a_capitale_ampio_si_torna_alla_quota_raccomandata():
|
||||
cfg = LS.split_config(50_000.0)
|
||||
assert cfg["IB"] == LS.IB_SHARE
|
||||
|
||||
|
||||
def test_la_quota_ib_non_supera_mai_il_tetto():
|
||||
for tot in (8_600.0, 10_000.0, 12_000.0, 100_000.0):
|
||||
cfg = LS.split_config(tot)
|
||||
if cfg:
|
||||
assert cfg["IB"] <= LS.IB_SHARE_MAX + 1e-12
|
||||
|
||||
|
||||
def test_due_conti_riducono_la_rovina(series):
|
||||
kw = dict(n_paths=600, years=6, seed=5, lump_usd=10_900.0)
|
||||
conc = LS.simulate({"Deribit": 1.0}, series, 0.05, **kw)
|
||||
split = LS.simulate(LS.split_config(11_500.0), series, 0.05, **kw)
|
||||
assert split["p_all_dead"] < conc["p_all_dead"]
|
||||
|
||||
|
||||
def test_la_rovina_non_dipende_da_cosa_sta_sulla_seconda_gamba(series):
|
||||
"""SPLIT-GTAA e SPLIT-CASSA devono avere la stessa P(perso tutto): la protezione viene dal
|
||||
numero di conti, non dai loro ritorni. E' la lettura centrale della sezione 2-bis."""
|
||||
kw = dict(n_paths=500, years=6, seed=11, lump_usd=10_900.0)
|
||||
cfg = LS.split_config(11_500.0)
|
||||
zero = {**series, "IB": series["IB"] * 0.0}
|
||||
assert (LS.simulate(cfg, series, 0.03, **kw)["p_all_dead"]
|
||||
== LS.simulate(cfg, zero, 0.03, **kw)["p_all_dead"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# costi dichiarati
|
||||
# ===========================================================================
|
||||
def test_haircut_toglie_il_drift_dichiarato(series):
|
||||
s = LS.haircut(series["IB"], 0.009)
|
||||
drift = (series["IB"].mean() - s.mean()) * 365
|
||||
assert drift == pytest.approx(0.009, rel=1e-9)
|
||||
|
||||
|
||||
def test_il_haircut_ib_somma_taglia_piccola_e_veicolo_ucits():
|
||||
"""0.8pp (CAGR 3.8%->3.0% a $3k, misurato 25/07) + 0.1pp (drag UCITS, misurato 26/07)."""
|
||||
assert LS.IB_HAIRCUT_YR == pytest.approx(0.009)
|
||||
|
||||
|
||||
def test_il_bersaglio_e_lo_stesso_per_entrambi_i_bracci():
|
||||
"""Conservativo CONTRO lo split (che avrebbe un muro leggermente diverso): se un giorno si
|
||||
usassero due bersagli, il confronto smetterebbe di essere leggibile."""
|
||||
import r0726_venue_risk as VR
|
||||
assert LS.TARGET == VR.TARGET
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Test della sorveglianza dei forward-monitor (src/live/monitor_health.py).
|
||||
|
||||
Tre gate pre-registrati (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decidono leggendo serie
|
||||
che nessuno sorvegliava. Questi test coprono le due modalita' di guasto (coda ferma, buchi
|
||||
interni) e — obbligatoriamente — i **controlli positivi**: un rilevatore che non ha mai segnalato
|
||||
nulla e' indistinguibile da uno rotto finche' non si prova che sa segnalare.
|
||||
|
||||
Il test che conta piu' di tutti e' `test_una_serie_bucata_non_passa_per_fresca`: e' l'unico guasto
|
||||
che una guardia di sola freschezza lascerebbe passare, ed e' quello che falsifica un gate senza
|
||||
farsi notare.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.monitor_health import ( # noqa: E402
|
||||
MIN_COVERAGE,
|
||||
MonitorSpec,
|
||||
alerts_from,
|
||||
assess,
|
||||
check_all,
|
||||
expected_bars,
|
||||
read_stamps,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
|
||||
DAILY = MonitorSpec("t", "t", "returns.jsonl", 24.0)
|
||||
GATED = MonitorSpec("t_gate", "t_gate", "returns.jsonl", 24.0, gate="2026-10-23 — gate XSR01")
|
||||
EQ = MonitorSpec("t_eq", "t_eq", "equity.csv", 24.0, calendar="equity", max_age_h=120.0)
|
||||
|
||||
|
||||
def _daily(n: int, end: datetime = NOW, step_h: float = 24.0, skip: set[int] | None = None):
|
||||
"""n timestamp a passo `step_h` che finiscono a `end`, opzionalmente con buchi."""
|
||||
skip = skip or set()
|
||||
return [end - timedelta(hours=step_h * (n - 1 - i)) for i in range(n) if i not in skip]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# stato normale
|
||||
# ===========================================================================
|
||||
def test_una_serie_completa_e_fresca_e_ok():
|
||||
r = assess(DAILY, _daily(30), NOW)
|
||||
assert r["status"] == "OK" and r["coverage"] == 1.0
|
||||
|
||||
|
||||
def test_ok_non_produce_allarmi():
|
||||
assert alerts_from([assess(DAILY, _daily(30), NOW)]) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CONTROLLI POSITIVI — la guardia sa segnalare?
|
||||
# ===========================================================================
|
||||
def test_un_monitor_fermo_viene_visto():
|
||||
"""Il guasto piu' semplice: il cron non gira piu'. Ultima barra di 5 giorni fa."""
|
||||
r = assess(DAILY, _daily(30, end=NOW - timedelta(days=5)), NOW)
|
||||
assert r["status"] == "FERMO"
|
||||
assert alerts_from([r])
|
||||
|
||||
|
||||
def test_una_serie_bucata_non_passa_per_fresca():
|
||||
"""IL test: il monitor gira, l'ultima barra e' di stanotte, ma ha perso il 30% delle barre di
|
||||
mezzo. Una guardia di sola freschezza direbbe OK, e il gate verrebbe deciso su meta' serie."""
|
||||
stamps = _daily(30, skip=set(range(5, 15))) # 20 barre su 30 attese
|
||||
r = assess(DAILY, stamps, NOW)
|
||||
assert r["status"] == "BUCATO"
|
||||
assert r["coverage"] < MIN_COVERAGE
|
||||
assert r["age_h"] < 24.0 # ...ed e' fresca: la freschezza non basta
|
||||
|
||||
|
||||
def test_una_serie_assente_e_un_allarme_non_un_silenzio():
|
||||
"""'Non vedo' non e' 'va tutto bene' — se lo stato e' perso, e' un evento."""
|
||||
r = assess(DAILY, [], NOW)
|
||||
assert r["status"] == "ASSENTE"
|
||||
assert alerts_from([r])
|
||||
|
||||
|
||||
def test_la_soglia_di_copertura_e_quella_del_veto_dvolspread():
|
||||
"""Riusata di proposito: una soglia diversa per monitor renderebbe i gate non confrontabili."""
|
||||
assert MIN_COVERAGE == 0.80
|
||||
assert assess(DAILY, _daily(30, skip={5, 6, 7, 8, 9}), NOW)["status"] == "OK" # 83%
|
||||
assert assess(DAILY, _daily(30, skip=set(range(5, 12))), NOW)["status"] == "BUCATO" # 77%
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# giovinezza, che non e' salute
|
||||
# ===========================================================================
|
||||
def test_un_monitor_con_una_sola_barra_non_e_giudicabile():
|
||||
r = assess(DAILY, _daily(1), NOW)
|
||||
assert r["status"] == "NUOVO" and r["coverage"] is None
|
||||
|
||||
|
||||
def test_nuovo_non_allerta_ma_non_e_ok():
|
||||
"""Un monitor appena partito non deve suonare, ma non deve nemmeno risultare sano:
|
||||
XSR01 e DVOLSPREAD hanno 2 barre e mancano mesi al loro gate."""
|
||||
r = assess(DAILY, _daily(1), NOW)
|
||||
assert alerts_from([r]) == [] and r["status"] != "OK"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# cadenze: sbagliarle vuol dire un falso allarme a settimana
|
||||
# ===========================================================================
|
||||
def test_un_monitor_orario_non_viene_scambiato_per_giornaliero():
|
||||
"""paper_prevday registra a barra oraria: 864 barre in 36 giorni sono complete, non un
|
||||
eccesso. Con la cadenza sbagliata la copertura uscirebbe al 2400%."""
|
||||
spec = MonitorSpec("h", "h", "returns.jsonl", 1.0)
|
||||
r = assess(spec, _daily(864, step_h=1.0), NOW)
|
||||
assert r["status"] == "OK" and r["expected"] == 864
|
||||
|
||||
|
||||
def test_il_weekend_non_ferma_un_monitor_di_borsa():
|
||||
"""paper_combo dipende dalle gambe IB: venerdi' e' l'ultima barra fino a lunedi'. Contare
|
||||
il weekend come eta' produrrebbe un allarme ogni lunedi' mattina."""
|
||||
ven = datetime(2026, 7, 24, 0, 0, tzinfo=timezone.utc)
|
||||
lun = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
|
||||
stamps = [ven - timedelta(days=k) for k in range(20, 0, -1)] + [ven]
|
||||
assert assess(EQ, stamps, lun)["status"] != "FERMO"
|
||||
|
||||
|
||||
def test_le_barre_attese_su_calendario_di_borsa_escludono_il_weekend():
|
||||
lun = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||
ven = datetime(2026, 7, 24, tzinfo=timezone.utc)
|
||||
assert expected_bars(lun, ven, 24.0, "equity") == 5 # non 5 giorni solari a caso: 5 sedute
|
||||
assert expected_bars(lun, ven, 24.0, "crypto") == 5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# escalation: il danno non e' il monitor, e' il gate
|
||||
# ===========================================================================
|
||||
def test_un_monitor_che_alimenta_un_gate_lo_dice_nell_allarme():
|
||||
a = alerts_from([assess(GATED, _daily(30, end=NOW - timedelta(days=5)), NOW)])
|
||||
assert a and "gate" in a[0].lower() and "XSR01" in a[0]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# lettura dei formati reali
|
||||
# ===========================================================================
|
||||
def test_legge_jsonl_con_ts_in_millisecondi(tmp_path):
|
||||
p = tmp_path / "returns.jsonl"
|
||||
p.write_text("\n".join(json.dumps({"ts": 1785024000000 + i * 86_400_000, "net": 0.0})
|
||||
for i in range(3)))
|
||||
s = read_stamps(p)
|
||||
assert len(s) == 3 and s[0] == datetime(2026, 7, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_legge_equity_csv_con_data_in_prima_colonna(tmp_path):
|
||||
p = tmp_path / "equity.csv"
|
||||
p.write_text("date,equity\n2026-07-24 00:00:00+00:00,2000.0\n2026-07-27 00:00:00+00:00,2010.0\n")
|
||||
s = read_stamps(p)
|
||||
assert len(s) == 2 and s[-1].day == 27
|
||||
|
||||
|
||||
def test_una_riga_corrotta_non_fa_esplodere_la_guardia(tmp_path):
|
||||
"""Una guardia che muore su una riga malformata smette di guardare proprio quando
|
||||
qualcosa e' andato storto."""
|
||||
p = tmp_path / "returns.jsonl"
|
||||
p.write_text('{"ts": 1785024000000}\nnon-json\n{"ts": 1785110400000}\n')
|
||||
assert len(read_stamps(p)) == 2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# integrazione sullo stato REALE del progetto
|
||||
# ===========================================================================
|
||||
def test_i_monitor_reali_sono_leggibili_e_giudicati():
|
||||
"""Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia
|
||||
li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota."""
|
||||
rows = check_all(ROOT, now=NOW)
|
||||
assert len(rows) == 6
|
||||
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
|
||||
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}
|
||||
Reference in New Issue
Block a user