diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 5eccf99..e1d104c 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -158,7 +158,7 @@ def factor_run(req: FactorBacktestRequest, request: Request): svc = FactorBacktestService(engine) end = req.end or date.today() - start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS) + start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS) _guard_server_backtest_range(start, end) symbols = req.symbols if req.symbols else None if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS: @@ -212,7 +212,7 @@ def factor_batch(req: FactorBatchRequest, request: Request): raise HTTPException(status_code=400, detail=f"不支持的因子: {', '.join(invalid)}") end = req.end or date.today() - start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS) + start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS) _guard_server_backtest_range(start, end) symbols = req.symbols if req.symbols else None if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS: diff --git a/backend/tests/backtest/test_factor_default_range.py b/backend/tests/backtest/test_factor_default_range.py new file mode 100644 index 0000000..7aa7c71 --- /dev/null +++ b/backend/tests/backtest/test_factor_default_range.py @@ -0,0 +1,89 @@ +"""因子回测默认区间回归测试。 + +覆盖 issue #202: `/api/backtest/factor/run` 与 `/api/backtest/factor/batch` +在省略 `start` 时错误使用了策略默认区间(3 年)而非因子默认区间(180 天)。 + +只关注「起始日解析」这一关注点, 用替身 Service 捕获传入配置, 不做真实数据加载。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import ClassVar + +import pytest + +from app.api import backtest as bt + + +@dataclass +class _DummyResult: + ok: bool = True + + +class _CapturingService: + """替身 FactorBacktestService: 捕获配置, 不触真实引擎。""" + + captured: ClassVar[list] = [] + + def __init__(self, engine): + pass + + def run(self, cfg): + _CapturingService.captured.append(cfg) + return _DummyResult() + + def run_batch(self, cfg): + _CapturingService.captured.append(cfg) + return _DummyResult() + + +class _Req: + """占位 request; _get_engine 已被 patch, 不会真正访问其属性。""" + + +@pytest.fixture +def patched(monkeypatch): + _CapturingService.captured = [] + monkeypatch.setattr(bt, "_get_engine", lambda request: object()) + # factor_run / factor_batch 内部 `from app.backtest.factor import FactorBacktestService` + # 读取的是模块属性, patch 源模块即可命中。 + import app.backtest.factor as factor_mod + monkeypatch.setattr(factor_mod, "FactorBacktestService", _CapturingService) + # 隔离守卫, 单测「起始日解析」。 + monkeypatch.setattr(bt, "_guard_server_backtest_range", lambda start, end: None) + return _CapturingService + + +def test_factor_run_omitted_start_uses_factor_default(patched): + end = date(2026, 8, 24) + bt.factor_run(bt.FactorBacktestRequest(factor_name="momentum_5d", end=end), _Req()) + cfg = patched.captured[-1] + assert cfg.start == end - timedelta(days=bt.FACTOR_DEFAULT_DAYS) + # 回归保护: 不得再退回策略默认的 3 年。 + assert cfg.start != end - timedelta(days=bt.STRATEGY_DEFAULT_DAYS) + + +def test_factor_batch_omitted_start_uses_factor_default(patched): + end = date(2026, 8, 24) + bt.factor_batch(bt.FactorBatchRequest(factor_names=["momentum_5d"], end=end), _Req()) + cfg = patched.captured[-1] + assert cfg.start == end - timedelta(days=bt.FACTOR_DEFAULT_DAYS) + assert cfg.start != end - timedelta(days=bt.STRATEGY_DEFAULT_DAYS) + + +def test_factor_run_explicit_null_start_means_all_history(patched): + """显式传 start=null 语义不变: 代表全部历史。""" + end = date(2026, 8, 24) + bt.factor_run(bt.FactorBacktestRequest(factor_name="momentum_5d", start=None, end=end), _Req()) + assert patched.captured[-1].start == date(1900, 1, 1) + + +def test_factor_run_explicit_start_passthrough(patched): + """显式日期原样透传。""" + end = date(2026, 8, 24) + bt.factor_run( + bt.FactorBacktestRequest(factor_name="momentum_5d", start=date(2026, 1, 1), end=end), + _Req(), + ) + assert patched.captured[-1].start == date(2026, 1, 1)