From 6655e425fa5418d8bedb84e958f33b05057c57a8 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 18 May 2026 17:04:15 +0000 Subject: [PATCH] fix(paper): ETH 5m allineato al tick + hardening GUI/compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 4 +- scripts/run_paper_trading.py | 39 ++- .../multi_swarm_core/dashboard/nicegui_app.py | 9 +- .../strategy_crypto/frontend/data.py | 16 +- .../tests/test_paper_runner_alignment.py | 70 ++++ state/validation-hardened-001.json | 324 ++++++++++++++++++ 6 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 src/strategy_crypto/tests/test_paper_runner_alignment.py create mode 100644 state/validation-hardened-001.json diff --git a/docker-compose.yml b/docker-compose.yml index 580611e..de66ebd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,8 +88,8 @@ services: <<: *swarm-env DASHBOARD_ROOT_PATH: /strategy_crypto_gui volumes: - # Dashboard legge solo strategy_crypto.db: state/ in read-only (WAL: vedi nota) - - ./state:/app/state:ro + # RW richiesto: SQLite WAL mode richiede write-access dal reader per SHM. + - ./state:/app/state entrypoint: - python - -m diff --git a/scripts/run_paper_trading.py b/scripts/run_paper_trading.py index 9199de2..264b45c 100644 --- a/scripts/run_paper_trading.py +++ b/scripts/run_paper_trading.py @@ -33,6 +33,36 @@ from strategy_crypto.backend import PaperExecutor, PaperRepository, Portfolio PROJECT_ROOT = Path(__file__).resolve().parent.parent +# Mapping timeframe stringa Cerbero -> minuti del bar. Le strategie tradano +# sul "bar appena chiuso", quindi end deve essere snappato al boundary del +# loro timeframe (NON sempre al top dell'ora) per evitare la regressione in +# cui ETH 5m veniva valutato una volta sola ogni 60 min. +_TIMEFRAME_MINUTES: dict[str, int] = { + "1m": 1, + "5m": 5, + "15m": 15, + "30m": 30, + "1h": 60, + "4h": 240, + "1d": 1440, +} + + +def _align_end_to_timeframe(now: datetime, timeframe: str) -> datetime: + """Snap ``now`` al boundary del bar timeframe (UTC, naive seconds). + + Es.: now=14:37:42, tf="5m" -> 14:35:00 + now=14:37:42, tf="1h" -> 14:00:00 + now=14:00:00, tf="1h" -> 14:00:00 + """ + bar_min = _TIMEFRAME_MINUTES[timeframe] + aligned = now.replace(second=0, microsecond=0) + if bar_min >= 1440: + return aligned.replace(hour=0, minute=0) + total_min = aligned.hour * 60 + aligned.minute + snapped = (total_min // bar_min) * bar_min + return aligned.replace(hour=snapped // 60, minute=snapped % 60) + def _default_strategies_dir() -> Path: """Cartella JSON shippata col package strategy_crypto.""" @@ -131,9 +161,12 @@ def main() -> None: now = datetime.now(UTC) last_prices: dict[str, float] = {} for asset, executor in zip(assets, executors, strict=True): - # fetch OHLCV most recent lookback bars - end = now.replace(minute=0, second=0, microsecond=0) - start = end - timedelta(hours=args.lookback_bars + 1) + # fetch OHLCV most recent lookback bars: end snappato al timeframe + # dell'asset, non sempre all'ora (altrimenti ETH 5m veniva valutato + # solo ogni 60 min, regressione vs backtest tunato 5m). + bar_min = _TIMEFRAME_MINUTES[asset.timeframe] + end = _align_end_to_timeframe(now, asset.timeframe) + start = end - timedelta(minutes=bar_min * (args.lookback_bars + 1)) req = OHLCVRequest( symbol=asset.symbol, timeframe=asset.timeframe, diff --git a/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py b/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py index 4ccfc38..70ddc32 100644 --- a/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py +++ b/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py @@ -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() diff --git a/src/strategy_crypto/strategy_crypto/frontend/data.py b/src/strategy_crypto/strategy_crypto/frontend/data.py index 0ecb6a7..3adf27a 100644 --- a/src/strategy_crypto/strategy_crypto/frontend/data.py +++ b/src/strategy_crypto/strategy_crypto/frontend/data.py @@ -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: diff --git a/src/strategy_crypto/tests/test_paper_runner_alignment.py b/src/strategy_crypto/tests/test_paper_runner_alignment.py new file mode 100644 index 0000000..67792c7 --- /dev/null +++ b/src/strategy_crypto/tests/test_paper_runner_alignment.py @@ -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)} diff --git a/state/validation-hardened-001.json b/state/validation-hardened-001.json new file mode 100644 index 0000000..d6cdc0e --- /dev/null +++ b/state/validation-hardened-001.json @@ -0,0 +1,324 @@ +{ + "run_id": "7f65bd1832d94c638b588aab02fb223e", + "run_name": "phase1-hardened-001", + "n_folds": 4, + "top_k_requested": 5, + "top_k_evaluated": 5, + "symbol": "BTC-PERPETUAL", + "timeframe": "1h", + "start": "2018-09-01T00:00:00+00:00", + "end": "2026-01-01T00:00:00+00:00", + "ohlcv_bars": 64297, + "results": [ + { + "genome_id": "9cf506b83bec55f6", + "fitness_is": 0.2847465321576384, + "sharpe_is": 0.6809931251900289, + "folds": [ + { + "fold": 0, + "fitness": 0.4454407113532186, + "sharpe": 0.940612398713799, + "dsr": 0.09856838950479485, + "dsr_pvalue": 0.9014316104952051, + "return": 0.12691347502077277, + "max_dd": 0.08467873586477132, + "n_trades": 50, + "test_start": "2022-05-02 12:00:00+00:00", + "test_end": "2023-04-02 08:00:00+00:00" + }, + { + "fold": 1, + "fitness": 0.3348901947844796, + "sharpe": 0.6215567075501345, + "dsr": 0.05615871498414856, + "dsr_pvalue": 0.9438412850158514, + "return": 0.1669550204052912, + "max_dd": 0.2425926649805225, + "n_trades": 60, + "test_start": "2023-04-02 09:00:00+00:00", + "test_end": "2024-03-02 05:00:00+00:00" + }, + { + "fold": 2, + "fitness": 0.08496628060413243, + "sharpe": -0.291593157960215, + "dsr": 0.006828013272159182, + "dsr_pvalue": 0.9931719867278408, + "return": -0.06496567446731383, + "max_dd": 0.1933746053658072, + "n_trades": 72, + "test_start": "2024-03-02 06:00:00+00:00", + "test_end": "2025-01-31 02:00:00+00:00" + }, + { + "fold": 3, + "fitness": 0.10547685784422405, + "sharpe": -0.04385303190774091, + "dsr": 0.013045759744378084, + "dsr_pvalue": 0.986954240255622, + "return": -0.003908970230222186, + "max_dd": 0.05825300658307936, + "n_trades": 31, + "test_start": "2025-01-31 03:00:00+00:00", + "test_end": "2025-12-31 23:00:00+00:00" + } + ], + "fitness_oos_mean": 0.24269351114651366, + "fitness_oos_min": 0.08496628060413243, + "fitness_oos_max": 0.4454407113532186, + "fitness_oos_std": 0.15273583802070415, + "sharpe_oos_mean": 0.3066807290989944, + "sharpe_oos_min": -0.291593157960215, + "robust_score": 0.08496628060413243 + }, + { + "genome_id": "9f1f7ffa40b4900a", + "fitness_is": 0.2653851546757454, + "sharpe_is": 0.7183647852569522, + "folds": [ + { + "fold": 0, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2022-05-02 12:00:00+00:00", + "test_end": "2023-04-02 08:00:00+00:00" + }, + { + "fold": 1, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2023-04-02 09:00:00+00:00", + "test_end": "2024-03-02 05:00:00+00:00" + }, + { + "fold": 2, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2024-03-02 06:00:00+00:00", + "test_end": "2025-01-31 02:00:00+00:00" + }, + { + "fold": 3, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2025-01-31 03:00:00+00:00", + "test_end": "2025-12-31 23:00:00+00:00" + } + ], + "fitness_oos_mean": 0.0, + "fitness_oos_min": 0.0, + "fitness_oos_max": 0.0, + "fitness_oos_std": 0.0, + "sharpe_oos_mean": 0.0, + "sharpe_oos_min": 0.0, + "robust_score": 0.0 + }, + { + "genome_id": "aff702f86c30e30f", + "fitness_is": 0.22070023288031862, + "sharpe_is": 0.49220575419744833, + "folds": [ + { + "fold": 0, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2022-05-02 12:00:00+00:00", + "test_end": "2023-04-02 08:00:00+00:00" + }, + { + "fold": 1, + "fitness": 0.0, + "sharpe": -1.3160615555061568, + "dsr": 2.0480110912755528e-14, + "dsr_pvalue": 0.9999999999999796, + "return": -0.004197667464114874, + "max_dd": 0.004197667464114874, + "n_trades": 1, + "test_start": "2023-04-02 09:00:00+00:00", + "test_end": "2024-03-02 05:00:00+00:00" + }, + { + "fold": 2, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2024-03-02 06:00:00+00:00", + "test_end": "2025-01-31 02:00:00+00:00" + }, + { + "fold": 3, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2025-01-31 03:00:00+00:00", + "test_end": "2025-12-31 23:00:00+00:00" + } + ], + "fitness_oos_mean": 0.0, + "fitness_oos_min": 0.0, + "fitness_oos_max": 0.0, + "fitness_oos_std": 0.0, + "sharpe_oos_mean": -0.3290153888765392, + "sharpe_oos_min": -1.3160615555061568, + "robust_score": 0.0 + }, + { + "genome_id": "43236904205071d5", + "fitness_is": 0.22070023288031862, + "sharpe_is": 0.49220575419744833, + "folds": [ + { + "fold": 0, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2022-05-02 12:00:00+00:00", + "test_end": "2023-04-02 08:00:00+00:00" + }, + { + "fold": 1, + "fitness": 0.0, + "sharpe": -1.3160615555061568, + "dsr": 2.0480110912755528e-14, + "dsr_pvalue": 0.9999999999999796, + "return": -0.004197667464114874, + "max_dd": 0.004197667464114874, + "n_trades": 1, + "test_start": "2023-04-02 09:00:00+00:00", + "test_end": "2024-03-02 05:00:00+00:00" + }, + { + "fold": 2, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2024-03-02 06:00:00+00:00", + "test_end": "2025-01-31 02:00:00+00:00" + }, + { + "fold": 3, + "fitness": 0.0, + "sharpe": 0.0, + "dsr": 0.0, + "dsr_pvalue": 1.0, + "return": 0.0, + "max_dd": 0.0, + "n_trades": 0, + "test_start": "2025-01-31 03:00:00+00:00", + "test_end": "2025-12-31 23:00:00+00:00" + } + ], + "fitness_oos_mean": 0.0, + "fitness_oos_min": 0.0, + "fitness_oos_max": 0.0, + "fitness_oos_std": 0.0, + "sharpe_oos_mean": -0.3290153888765392, + "sharpe_oos_min": -1.3160615555061568, + "robust_score": 0.0 + }, + { + "genome_id": "78a445d3043e0771", + "fitness_is": 0.20950868586433583, + "sharpe_is": 0.2940379509455447, + "folds": [ + { + "fold": 0, + "fitness": 0.07182313189843528, + "sharpe": -0.5971649832941454, + "dsr": 0.0029614586254508167, + "dsr_pvalue": 0.9970385413745492, + "return": -0.10657245144370586, + "max_dd": 0.17076725093180228, + "n_trades": 14, + "test_start": "2022-05-02 12:00:00+00:00", + "test_end": "2023-04-02 08:00:00+00:00" + }, + { + "fold": 1, + "fitness": 0.04914620786914511, + "sharpe": -0.7350195766937807, + "dsr": 0.0007433210570883239, + "dsr_pvalue": 0.9992566789429117, + "return": -0.03472119511680272, + "max_dd": 0.060780295013844174, + "n_trades": 20, + "test_start": "2023-04-02 09:00:00+00:00", + "test_end": "2024-03-02 05:00:00+00:00" + }, + { + "fold": 2, + "fitness": 0.0, + "sharpe": 1.2071345821102195, + "dsr": 0.14855640183813817, + "dsr_pvalue": 0.8514435981618618, + "return": 0.12296871148346566, + "max_dd": 0.05065940776643054, + "n_trades": 9, + "test_start": "2024-03-02 06:00:00+00:00", + "test_end": "2025-01-31 02:00:00+00:00" + }, + { + "fold": 3, + "fitness": 0.0, + "sharpe": -1.6874561123211371, + "dsr": 9.510326955509528e-06, + "dsr_pvalue": 0.9999904896730445, + "return": -0.03197955364502458, + "max_dd": 0.032239270519921044, + "n_trades": 7, + "test_start": "2025-01-31 03:00:00+00:00", + "test_end": "2025-12-31 23:00:00+00:00" + } + ], + "fitness_oos_mean": 0.0302423349418951, + "fitness_oos_min": 0.0, + "fitness_oos_max": 0.07182313189843528, + "fitness_oos_std": 0.03128704497338385, + "sharpe_oos_mean": -0.4531265225497109, + "sharpe_oos_min": -1.6874561123211371, + "robust_score": 0.0 + } + ] +} \ No newline at end of file