fix(paper): ETH 5m allineato al tick + hardening GUI/compose

Bug principale: in scripts/run_paper_trading.py il fetch usava
end = now.replace(minute=0,...), troncando sempre all'ora. ETH è
dichiarato timeframe=5m (commit 23b7273) ma di fatto veniva
valutato 1 volta ogni 60 min — 502 poll del run 39e027df hanno
prodotto solo 43 evaluazioni/asset, tutte a HH:00. Il commento
in load_assets segnala esplicitamente che a 1h la strategia
perde -33% su 7y: regressione vs backtest.

Fix: helper _align_end_to_timeframe(now, timeframe) snappa end
al boundary nativo dell'asset. Mappa 1m/5m/15m/30m/1h/4h/1d.
Test regression in src/strategy_crypto/tests con 9 casi.

Hardening accessorio incluso nello stesso commit:
- docker-compose.yml: state/ in RW per strategy-crypto-gui
  (SQLite WAL richiede SHM writable anche da reader).
- multi_swarm_core/dashboard/nicegui_app.py: ui.timer ora
  deactivate on_disconnect su 3 pagine (index/convergence/genomes)
  per evitare leak di timer dopo client disconnect.
- strategy_crypto/frontend/data.py: retry 5s su sqlite.connect
  per cold-start race quando GUI parte prima del paper writer.
- state/validation-hardened-001.json: output WFA tooling
  multi-fold del run phase1-hardened-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-18 17:04:15 +00:00
parent 23b7273e71
commit 6655e425fa
6 changed files with 451 additions and 11 deletions
@@ -263,7 +263,8 @@ def index() -> None:
refresh()
select.on_value_change(on_select_change)
ui.timer(REFRESH_INTERVAL_S, refresh)
_timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh()
@@ -353,7 +354,8 @@ def convergence() -> None:
refresh()
select.on_value_change(on_select_change)
ui.timer(REFRESH_INTERVAL_S, refresh)
_timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh()
@@ -535,7 +537,8 @@ def genomes() -> None:
select.on_value_change(on_select_change)
top_k_select.on_value_change(lambda _: refresh())
top_table.on("selection", on_row_selected)
ui.timer(REFRESH_INTERVAL_S, refresh)
_timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh()
@@ -7,6 +7,7 @@ from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
@@ -14,9 +15,18 @@ import pandas as pd # type: ignore[import-untyped]
def _paper_conn(db_path: str | Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
# Cold-start race: GUI può avviarsi prima che il paper writer crei il file.
db_path_str = str(db_path)
deadline = time.monotonic() + 5.0
while True:
try:
conn = sqlite3.connect(db_path_str, timeout=5.0)
conn.row_factory = sqlite3.Row
return conn
except sqlite3.OperationalError:
if time.monotonic() >= deadline:
raise
time.sleep(1.0)
def paper_runs_df(db_path: str | Path) -> pd.DataFrame:
@@ -0,0 +1,70 @@
"""Regression guard: end-of-window snap deve seguire il timeframe dell'asset.
Bug originale (scripts/run_paper_trading.py): ``end = now.replace(minute=0,...)``
snappava sempre all'ora; ETH 5m veniva quindi valutato 1 volta ogni 60 min
invece di ogni 5 min, riducendo la fedelta' al backtest tunato 5m.
"""
from __future__ import annotations
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[3]
_RUNNER_PATH = _REPO_ROOT / "scripts" / "run_paper_trading.py"
def _load_runner_module():
spec = importlib.util.spec_from_file_location("run_paper_trading", _RUNNER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules["run_paper_trading"] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def runner():
return _load_runner_module()
@pytest.mark.parametrize(
"now, tf, expected",
[
# 5m: snap al boundary di 5 min, NON all'ora
(datetime(2026, 5, 18, 14, 37, 42, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 35, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 35, 0, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 35, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 34, 59, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 30, tzinfo=UTC)),
# 1h: comportamento storico preservato
(datetime(2026, 5, 18, 14, 37, 42, tzinfo=UTC), "1h", datetime(2026, 5, 18, 14, 0, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 0, 0, tzinfo=UTC), "1h", datetime(2026, 5, 18, 14, 0, tzinfo=UTC)),
# 15m / 4h
(datetime(2026, 5, 18, 14, 22, 0, tzinfo=UTC), "15m", datetime(2026, 5, 18, 14, 15, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 22, 0, tzinfo=UTC), "4h", datetime(2026, 5, 18, 12, 0, tzinfo=UTC)),
],
)
def test_align_end_to_timeframe(runner, now, tf, expected) -> None:
assert runner._align_end_to_timeframe(now, tf) == expected
def test_align_end_5m_advances_every_5_minutes(runner) -> None:
"""Bug-regression: chiamate consecutive a 5 min di distanza devono
produrre end DIVERSI per tf=5m (prima del fix erano identici)."""
a = datetime(2026, 5, 18, 14, 30, 0, tzinfo=UTC)
b = datetime(2026, 5, 18, 14, 35, 0, tzinfo=UTC)
c = datetime(2026, 5, 18, 14, 40, 0, tzinfo=UTC)
ends = {runner._align_end_to_timeframe(t, "5m") for t in (a, b, c)}
assert len(ends) == 3
def test_align_end_1h_stable_within_hour(runner) -> None:
"""Per tf=1h, chiamate dentro la stessa ora devono dare lo stesso end."""
ends = {
runner._align_end_to_timeframe(datetime(2026, 5, 18, 14, m, 0, tzinfo=UTC), "1h")
for m in (0, 15, 30, 45, 59)
}
assert ends == {datetime(2026, 5, 18, 14, 0, tzinfo=UTC)}