Add walk-forward backtest optimization to mitigate signal overfitting (item m)

Rolling train/test folds over 3 years of data auto-optimize the three
cheap-to-tune trading parameters (STRONG/BUY score thresholds, max hold
time) via grid search on each fold's train window, then evaluate purely
on the held-out test window. Stitching all out-of-sample results gives
an honest performance estimate uninflated by tuning against the same
data used to score it.

Split signal_scoring.py's expensive 13-algorithm scoring from its cheap
final threshold classification so grid search can replay many parameter
combinations without recomputing indicators each time. Moved the
backtest engine (fetch/precompute/simulate) out of the API layer into
app/services/backtest_engine.py so both /backtest/run and the new
walk-forward optimizer share one implementation instead of drifting
copies — same rationale as the earlier signal_service.py split (item h).

Also merges two long-diverged Alembic migration heads discovered while
adding the walk_forward_results table, so `alembic upgrade head` has a
single target again.

New: POST/GET/DELETE /walk-forward/* endpoints, a Walk-Forward tab on
the Backtest page (fold table, out-of-sample equity curve, run history).
19 new backend tests (153 total, all passing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 09:14:32 +07:00
parent 95119b039e
commit 625c2b3773
12 changed files with 1932 additions and 368 deletions
@@ -0,0 +1,52 @@
"""Add walk_forward_results table
Stores walk-forward backtest runs: rolling train/test folds with
per-fold optimized parameters and in-sample vs out-of-sample metrics,
plus the aggregated out-of-sample summary. `result_json` holds the
full fold-by-fold detail; the flat columns are for fast history listing.
Revision ID: add_walk_forward_results
Revises: merge_heads_1
Create Date: 2026-07-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "add_walk_forward_results"
down_revision: Union[str, None] = "merge_heads_1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"walk_forward_results",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("symbol", sa.String(length=50), nullable=False),
sa.Column("exchange", sa.String(length=20), nullable=False),
sa.Column("timeframe", sa.String(length=10), nullable=False),
sa.Column("total_days", sa.Integer(), nullable=False),
sa.Column("train_days", sa.Integer(), nullable=False),
sa.Column("test_days", sa.Integer(), nullable=False),
sa.Column("folds_count", sa.Integer(), nullable=False),
sa.Column("oos_trades", sa.Integer(), nullable=False),
sa.Column("oos_win_rate", sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column("oos_total_pnl", sa.Numeric(precision=20, scale=8), nullable=True),
sa.Column("oos_profit_factor", sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column("oos_max_drawdown_pct", sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column("result_json", sa.Text(), nullable=False, comment="Full fold-by-fold detail + stitched OOS equity curve"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_walk_forward_results_user_id", "walk_forward_results", ["user_id"], unique=False)
op.create_index("ix_walk_forward_results_created_at", "walk_forward_results", ["created_at"], unique=False)
def downgrade() -> None:
op.drop_index("ix_walk_forward_results_created_at", table_name="walk_forward_results")
op.drop_index("ix_walk_forward_results_user_id", table_name="walk_forward_results")
op.drop_table("walk_forward_results")
+24
View File
@@ -0,0 +1,24 @@
"""merge divergent heads (1b3f1630986f, 4_add_sl_tp_columns)
Both branched off add_candle_partitions independently, leaving two
unmerged heads. This is a no-op merge so `alembic upgrade head` has a
single target again.
Revision ID: merge_heads_1
Revises: 1b3f1630986f, 4_add_sl_tp_columns
Create Date: 2026-07-04
"""
from typing import Sequence, Union
revision: str = "merge_heads_1"
down_revision: Union[str, Sequence[str], None] = ("1b3f1630986f", "4_add_sl_tp_columns")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass