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:
+88
-33
@@ -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") == []
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user