revisione 02/09, seconda tornata: il classificatore dei movimenti era cieco ~23 ore al giorno

Il feed 1h certificato si ferma alle 00:00: per le letture successive `asof` dava la stessa
barra a t0 e t1, mercato "fermo" = 0, e qualunque calo >=10% del giorno sarebbe stato un
"movimento" scorporato come prelievo (giornale dal 25/08, report da oggi). Con feed assente
tutto era "ambiguo" e il report stampava e1/e0-1 sotto l'etichetta TWR.

- journal.movimenti_capitale: stato "mercato non misurabile" (feed fermo/assente/barra
  mancante) => ambiguo con la ragione; leva_tetto = min(frac x n x scala, LEVA_LORDA_MAX) (P1).
- journal.rendimento_twr: salto non classificabile => twr E trading None con motivo.
- journal.pnl_giorno CHIAMA rendimento_twr (prima rifaceva cum - certi: "entrambi la chiamano"
  era falso); la pagina stampa il TWR; analista qualifica il cumulato "di cui versati".
- trades_db --report: la classe di ogni salto con la sua misura (mercato max a leva piena).
- book_execute docstring: banda dello stop -33,5%/-26,5% (era invertita), "non costa ordini"
  -> micro-ordini di ri-taglia (22/48, C2), latenza <=1h solo con feed fresca, conteggi di
  r0823 al posto di "raddoppia" (2 contro 1 e' rotolante vs pavimento sulla riga 4h; la
  peggiore misurata e' 24h).
- test_book_cadenza: LTF_MIN importato da skyhook; tutte le righe attive della crontab (una
  sola); tre stati dello skip (col progetto ma senza cron_book => ROSSO); slot di release
  testato con venue_probe (il :07 come controllo positivo); parser regolare, passo > 0.
- r0823_sl_anchor: guardia sul :47 (era sul vecchio :07), prosa al passato; cron_chain.sh idem.
- CLAUDE.md: §5.7 frase invertita corretta, §5.14 limite vero (non l'artefatto della fixture),
  §2 ora della LETTURA (13:47Z), nuovo debito §5.15 (USDE fuori dal bound di mercato).
- diari: tempi "scritto" corretti coi commit; sezione "Seconda tornata".

Test +11 (858 verdi).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
This commit is contained in:
Adriano Dal Pastro
2026-09-02 15:33:13 +00:00
parent 835e0c8666
commit 1279ea605a
13 changed files with 328 additions and 90 deletions
+88 -33
View File
@@ -1,9 +1,10 @@
"""La cadenza del libro live e' ORARIA, e le tre dichiarazioni devono restare d'accordo.
Debito §5.7 (chiuso il 2026-09-02): il docstring di `book_execute.py` prescriveva «ogni ~230
minuti» mentre il cron gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che sulla
riga 4h BTC RADDOPPIA gli scatti del disaster-SL rotolante (2 contro 1; a 24h ETH arriva a 5).
Chi avesse "corretto" il cron verso il docstring avrebbe spostato il libro sulla riga peggiore.
minuti» mentre il cron gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che gli
scatti del disaster-SL rotolante dipendono dalla cadenza — 1h: BTC 0 / ETH 1 in 7-8 anni; 4h:
BTC 2 (contro 1 del pavimento); 24h: ETH 5 con 2 in 30 giorni. Chi avesse "corretto" il CRON
verso il docstring avrebbe spostato il libro dalla riga 1h a quella 4h.
P1: il bersaglio si DERIVA da cio' che gira. Qui le fonti sono tre — il docstring (cio' che un
umano legge), `cron_book.sh` (cio' che dichiara di essere installato) e la crontab (cio' che
@@ -15,19 +16,30 @@ import ast
import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import venue_probe as VP # noqa: E402 lo slot di release, dichiarato li'
from src.strategies.skyhook import LTF_MIN # noqa: E402 la griglia di SKH01, NON ridichiarata
BOOK_EXECUTE = ROOT / "scripts" / "live" / "book_execute.py"
CRON_BOOK = ROOT / "scripts" / "cron_book.sh"
GRIGLIA_SKH_MIN = 230 # data1 di SKH01 (src/strategies/skyhook.py): la riga da NON usare
RIGA_PEGGIORE_MIN = 240 # 4h: dove BTC raddoppia gli scatti (r0823_sl_anchor.py)
RIGA_4H_MIN = 240 # la riga in cui il rotolante di BTC passa da 0 a 2 scatti (r0823_sl_anchor.py);
# la PEGGIORE misurata e' 24h (ETH 5, 2 in 30 giorni), non questa
_RX_CRON5 = re.compile(r"`([\d*/,-]+(?: [\d*/,-]+){4})`")
def cadenza_minuti(expr: str) -> int:
"""Minuti fra due esecuzioni di un'espressione crontab a 5 campi, per i soli casi regolari."""
"""Minuti fra due esecuzioni di un'espressione crontab a 5 campi, SOLO per cadenze regolari
(intervalli tutti uguali): `47 * * * *` -> 60, `*/30 * * * *` -> 30, `7 */4 * * *` -> 240.
`*/45` (intervalli 45/15), `0 */5` (ultimo intervallo 4h) e `*/0` vengono RIFIUTATI."""
campi = expr.split()
assert len(campi) == 5, expr
m, h, dom, mon, dow = campi
@@ -35,9 +47,15 @@ def cadenza_minuti(expr: str) -> int:
if h == "*" and m.isdigit():
return 60
if h == "*" and m.startswith("*/"):
return int(m[2:])
passo = int(m[2:])
if passo <= 0 or 60 % passo:
raise ValueError(f"passo di minuti non regolare: {expr}")
return passo
if h.startswith("*/") and m.isdigit():
return 60 * int(h[2:])
passo = int(h[2:])
if passo <= 0 or 24 % passo:
raise ValueError(f"passo di ore non regolare: {expr}")
return 60 * passo
raise ValueError(f"espressione non riconosciuta: {expr}")
@@ -46,25 +64,30 @@ def _docstring() -> str:
def _cron_dichiarato() -> str:
"""L'espressione crontab che `cron_book.sh` dichiara nel suo intestazione, fra backtick."""
m = re.search(r"`(\d+ \* \* \* \*)`", CRON_BOOK.read_text())
assert m, "cron_book.sh non dichiara piu' la sua riga di crontab"
"""La prima espressione crontab a 5 campi che `cron_book.sh` dichiara fra backtick — di
qualunque forma: e' `cadenza_minuti` a giudicarla, non questa regex (P4: un'espressione
presente ma non oraria deve fallire come 'non oraria', non come 'assente')."""
m = _RX_CRON5.search(CRON_BOOK.read_text())
assert m, "cron_book.sh non dichiara piu' la sua riga di crontab fra backtick"
return m.group(1)
def _cron_installato() -> str | None:
def _crontab() -> str | None:
"""Testo della crontab dell'utente, None se non leggibile (P5: distinto da 'vuota')."""
if not shutil.which("crontab"):
return None
try:
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
except Exception:
return None
if out.returncode != 0:
return None
for riga in out.stdout.splitlines():
if "cron_book.sh" in riga and not riga.lstrip().startswith("#"):
return " ".join(riga.split()[:5])
return None
return out.stdout if out.returncode == 0 else None
def _righe_attive(testo: str, marcatore: str) -> list[str]:
"""Le espressioni (5 campi) di TUTTE le righe attive che citano `marcatore`: due righe sono
due esecuzioni, e la seconda non deve nascondersi dietro la prima."""
return [" ".join(r.split()[:5]) for r in testo.splitlines()
if marcatore in r and not r.lstrip().startswith("#")]
# ---------------------------------------------------------------- regressione sul docstring
@@ -114,11 +137,11 @@ def test_il_docstring_dichiara_la_stessa_riga_del_cron():
# ---------------------------------------------------------------- cron_book.sh
def test_cron_book_e_orario_e_sta_lontano_dalla_riga_peggiore():
expr = _cron_dichiarato()
c = cadenza_minuti(expr)
def test_cron_book_e_orario_sotto_la_griglia_skh_e_sotto_la_riga_4h():
c = cadenza_minuti(_cron_dichiarato())
assert c == 60
assert c < GRIGLIA_SKH_MIN < RIGA_PEGGIORE_MIN
assert c < LTF_MIN, "la cadenza deve stare SOTTO la griglia di SKH01 (latenza <= 1 barra)"
assert c < RIGA_4H_MIN, "la riga 4h e' quella in cui il rotolante di BTC fa 2 scatti"
assert "ORARIA" in CRON_BOOK.read_text()
@@ -128,25 +151,57 @@ def test_cron_book_evita_il_minuto_tondo():
assert minuto != 0
def test_cron_book_evita_lo_slot_di_release_deribit():
# vincolo 2 del commento di cron_book.sh — il motivo vero del :07 -> :47 (4 giri morti in
# 63 giorni): il giro del martedi' alle 09:MM deve cadere FUORI dallo slot dichiarato in
# venue_probe (derivato, non riscritto). Il :07 lo violava; se qualcuno tornasse a :15
# con docstring e crontab coerenti, gli altri test passerebbero e questo no.
minuto = int(_cron_dichiarato().split()[0])
base = datetime(2026, 9, 1, tzinfo=timezone.utc) # un lunedi'
martedi = base + timedelta(days=(VP.RELEASE_WEEKDAY - base.weekday()) % 7)
giro = martedi.replace(hour=VP.RELEASE_START_MIN // 60, minute=minuto)
assert not VP.in_release_window(giro), f"il giro :{minuto:02d} cade nello slot di release"
assert VP.in_release_window(giro.replace(minute=7)), "controllo positivo: il :07 ci cadeva"
@pytest.mark.parametrize("expr,atteso", [("47 * * * *", 60), ("*/30 * * * *", 30),
("7 */4 * * *", 240), ("0 */24 * * *", 1440)])
def test_il_parser_della_cadenza(expr, atteso):
assert cadenza_minuti(expr) == atteso
def test_il_parser_rifiuta_cio_che_non_sa_leggere():
@pytest.mark.parametrize("expr", ["47 9 * * 2", "*/45 * * * *", "0 */5 * * *", "*/0 * * * *",
"47 * 1 * *"])
def test_il_parser_rifiuta_cio_che_non_e_regolare(expr):
with pytest.raises((ValueError, AssertionError)):
cadenza_minuti("47 9 * * 2")
cadenza_minuti(expr)
# ---------------------------------------------------------------- la crontab che GIRA
def test_la_crontab_installata_e_quella_dichiarata():
"""P16: il cron gira dalla macchina, non dal repo. Dove la crontab e' leggibile (la VPS),
deve coincidere con cio' che cron_book.sh dichiara; altrove il test si dichiara saltato,
non verde (P5: 'non vedo' non e' 'va tutto bene')."""
vivo = _cron_installato()
if vivo is None:
pytest.skip("crontab non leggibile o senza cron_book.sh: non verificabile qui")
assert vivo == _cron_dichiarato()
assert cadenza_minuti(vivo) == 60
def test_la_crontab_installata_e_quella_dichiarata_e_una_sola():
"""P16: il cron gira dalla macchina, non dal repo. TRE stati, non due (P5):
- crontab NON leggibile -> SALTATO (non verificabile qui);
- leggibile, e questo progetto non ci compare affatto -> SALTATO (macchina di sviluppo);
- leggibile con altre righe del progetto ma SENZA cron_book.sh attivo -> ROSSO: il libro
non e' schedulato dove il progetto e' installato (una riga commentata per manutenzione e
dimenticata e' esattamente questo caso).
E la riga deve essere UNA: due righe attive sono due esecuzioni l'ora."""
testo = _crontab()
if testo is None:
pytest.skip("crontab non leggibile: non verificabile qui")
if str(ROOT) not in testo and ROOT.name not in testo:
pytest.skip("la crontab non cita questo progetto: non e' la macchina che lo esegue")
righe = _righe_attive(testo, "cron_book.sh")
assert righe, "questa macchina esegue il progetto ma cron_book.sh NON e' schedulato"
assert len(righe) == 1, f"cron_book.sh schedulato {len(righe)} volte: {righe}"
assert righe[0] == _cron_dichiarato()
assert cadenza_minuti(righe[0]) == 60
def test_due_righe_attive_non_si_nascondono_dietro_la_prima():
finta = ("47 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"# 7 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"17 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n")
assert _righe_attive(finta, "cron_book.sh") == ["47 * * * *", "17 * * * *"]
assert _righe_attive("# 47 * * * * /x/cron_book.sh\n", "cron_book.sh") == []
+58
View File
@@ -601,3 +601,61 @@ def test_twr_senza_tempo_a_mercato_e_zero_non_None(db, monkeypatch):
("2026-08-24T11:47:01+00:00", 2000.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] == 0.0 and r["segmenti"] == [] and r["trading"] == pytest.approx(0.0)
# ------------------------------------------------- mercato NON misurabile (revisione 02/09)
# Il feed 1h certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
# `asof` restituiva la STESSA barra per le due letture, max_mkt 0, e qualunque calo >=10%
# diventava un "movimento" — un crash stampato come prelievo. E con il feed assente tutto
# era "ambiguo", certi=0, e il TWR tornava a essere e1/e0-1 sotto l'etichetta sbagliata.
def test_un_salto_oltre_la_fine_del_feed_e_ambiguo_con_la_ragione(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) # copre fino al 27/08
_serie_equity(db, [("2026-08-28T10:47:01+00:00", 2000.0),
("2026-08-28T11:47:01+00:00", 1760.0)]) # -12% dopo la fine
p = J.pnl_giorno(db, date(2026, 8, 28))
ev = p["movimenti_giorno"]
assert ev[0]["classe"] == "ambiguo" and ev[0]["mercato_max_pct"] is None
assert "fermo a 2026-08-27" in ev[0]["mercato_nota"]
assert p["mov_giorno"] == 0.0 # NON scorporato
assert p["trading_da_arming"] is None and p["twr_da_arming"] is None
assert "non classificabile" in p["twr_motivo"]
def test_con_il_feed_assente_il_twr_e_None_non_il_grezzo(db, monkeypatch):
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)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["trading"] is None
assert "non leggibile" in r["motivo"]
assert r["eventi"][0]["classe"] == "ambiguo"
def test_il_giornale_e_il_report_usano_la_stessa_funzione(db, monkeypatch):
# P1, revisione 02/09: pnl_giorno non rifa' piu' `cum - certi`; prende trading e TWR da
# rendimento_twr, e la pagina lo stampa accanto al cumulato
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0),
("2026-08-25T11:47:01+00:00", 2030.0),
("2026-08-25T12:47:01+00:00", 2009.7)])
p = J.pnl_giorno(db, date(2026, 8, 25))
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert p["trading_da_arming"] == pytest.approx(r["trading"])
assert p["twr_da_arming"] == pytest.approx(r["twr"]) == pytest.approx(1.05 * 0.99 - 1)
md = J.rendi_markdown(db, _voce(pnl=p, giorno="2026-08-25"))
assert "TWR **+3.95%**" in md
def test_la_leva_del_classificatore_include_la_scala_e_il_tetto_di_codice(db, monkeypatch, tmp_path):
# P1: la chiave di scala del 01/09 entra nel bound da sola; e il fallback e' il tetto di CODICE
from src.live import book as B
cfg = tmp_path / "live.json"
cfg.write_text(json.dumps({"max_notional_per_asset_frac": 0.5, "book_scale_k": 1.25}))
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path); (tmp_path / "config").mkdir()
cfg.rename(tmp_path / "config" / "live.json")
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", 2000.0)])
mv = J.movimenti_capitale(db, "2026-08-25T23:59:59+00:00")
assert mv["leva_tetto"] == pytest.approx(min(0.5 * 2 * 1.25, B.LEVA_LORDA_MAX))
+16
View File
@@ -80,3 +80,19 @@ def test_il_report_a_base_zero_dice_n_d_e_non_l_intero_conto(monkeypatch, capsys
assert "trading da arming : n/d" in out and "TWR : n/d" in out
assert "trading da arming : +610.00" not in out # l'intero conto NON e' "trading"
assert "(+610.00 di equity, movimenti di capitale INCLUSI)" in out # il fatto resta, etichettato
def test_il_report_con_il_feed_assente_non_stampa_il_grezzo_come_TWR(monkeypatch, capsys):
# revisione 02/09: con data/raw assente (host ripristinato prima di rebuild_history) ogni
# salto era "ambiguo", certi=0, e il report stampava +243% sotto l'etichetta TWR
monkeypatch.setattr(J, "_px_1h", lambda a: None)
con = T.connect()
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 2000.0),
("2026-08-26T10:47:01+00:00", 2020.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert "TWR : n/d" in out and "trading da arming : n/d" in out
assert "% =" not in out and "+236" not in out
assert "[ambiguo: mercato non misurabile" in out