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:
@@ -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)}
|
||||
Reference in New Issue
Block a user