Files
tick-stock-panel/backend/tests/backtest/test_matrix_compute_cache.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

235 lines
7.9 KiB
Python

from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import polars as pl
import pytest
from app.backtest.matrix import (
MatrixComputeCache,
MatrixPipelineConfig,
MatrixStrategyPipeline,
build_market_data_matrix,
rolling_max,
rolling_mean,
rolling_min,
rolling_quantile,
rolling_std,
rolling_sum,
shift,
)
from app.strategy.engine import StrategyEngine
REPO_ROOT = Path(__file__).resolve().parents[3]
def _market(scale: float = 1.0):
start = date(2024, 1, 1)
rows = []
for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")):
for time_id in range(140):
close = scale * (10.0 + asset_id + time_id * 0.01)
rows.append({
"symbol": symbol,
"name": symbol,
"date": start + timedelta(days=time_id),
"open": close * 0.99,
"high": close * 1.01,
"low": close * 0.98,
"close": close,
"volume": 100_000.0 + time_id,
"amount": close * 100_000.0,
"signal_limit_up": False,
"signal_limit_down": False,
})
return build_market_data_matrix(pl.DataFrame(rows), field_columns={"amount"})
def test_cache_hits_are_read_only_and_temporary_arrays_use_content_fingerprint():
market = _market()
cache = MatrixComputeCache(max_bytes=8 * 1024 * 1024)
with cache.activate(market):
first = rolling_mean(market.close, 5)
second = rolling_mean(market.close, 5)
temp_first = rolling_mean(market.close * np.float32(2.0), 7)
temp_second = rolling_mean(market.close * np.float32(2.0), 7)
assert first is second
assert temp_first is temp_second
assert first.flags.writeable is False
stats = cache.snapshot()
assert stats["operations"]["rolling_mean"]["hits"] == 2
assert stats["fingerprint_bytes"] > 0
def test_cache_key_separates_market_lineage_and_operator_parameters():
first_market = _market()
second_market = _market()
cache = MatrixComputeCache(max_bytes=8 * 1024 * 1024)
with cache.activate(first_market):
first_window = rolling_min(first_market.close, 3)
second_window = rolling_min(first_market.close, 4)
with cache.activate(second_market):
other_market = rolling_min(second_market.close, 3)
assert first_window is not second_window
assert first_window is not other_market
assert cache.snapshot()["operations"]["rolling_min"]["misses"] == 3
def test_cache_lru_evicts_by_bytes_and_close_releases_all_entries():
market = _market()
item_bytes = market.close.nbytes
cache = MatrixComputeCache(max_bytes=item_bytes, max_item_bytes=item_bytes)
with cache.activate(market):
rolling_min(market.close, 3)
rolling_max(market.close, 3)
before_close = cache.snapshot()
assert before_close["entries"] == 1
assert before_close["evictions"] == 1
cache.close()
assert cache.snapshot()["current_bytes"] == 0
with pytest.raises(RuntimeError, match="closed"), cache.activate(market):
pass
def test_shift_stays_out_of_cache_to_protect_expensive_working_set():
market = _market()
cache = MatrixComputeCache(max_bytes=8 * 1024 * 1024)
with cache.activate(market):
first = shift(market.close, 1)
second = shift(market.close, 1)
assert first is not second
assert "shift" not in cache.snapshot()["operations"]
def test_additional_rolling_operators_match_pandas_and_hit_cache():
market = _market()
cache = MatrixComputeCache(max_bytes=8 * 1024 * 1024)
expected = pd.DataFrame(market.close)
with cache.activate(market):
actual_sum = rolling_sum(market.close, 5)
actual_std = rolling_std(market.close, 5)
actual_quantile = rolling_quantile(market.close, 5, 0.25)
assert rolling_sum(market.close, 5) is actual_sum
assert rolling_std(market.close, 5) is actual_std
assert rolling_quantile(market.close, 5, 0.25) is actual_quantile
np.testing.assert_allclose(actual_sum, expected.rolling(5).sum(), equal_nan=True)
np.testing.assert_allclose(actual_std, expected.rolling(5).std(ddof=0), atol=1e-6, equal_nan=True)
np.testing.assert_allclose(
actual_quantile,
expected.rolling(5).quantile(0.25),
atol=1e-6,
equal_nan=True,
)
operations = cache.snapshot()["operations"]
assert operations["rolling_sum"]["hits"] == 1
assert operations["rolling_std"]["hits"] == 1
assert operations["rolling_quantile"]["hits"] == 1
def test_builtin_matrix_strategy_formula_is_unchanged_with_cache():
market = _market()
strategy_path = (
REPO_ROOT / "backend" / "tests" / "fixtures" / "strategies" / "macd_golden.py"
)
strategy_def = StrategyEngine._load_file(strategy_path)
strategy = strategy_def.matrix_strategy
assert strategy is not None
params = {}
uncached = strategy.compute_signals(market, params)
cache = MatrixComputeCache(max_bytes=64 * 1024 * 1024)
with cache.activate(market):
first = strategy.compute_signals(market, params)
second = strategy.compute_signals(market, params)
np.testing.assert_array_equal(first.entry, uncached.entry)
np.testing.assert_array_equal(second.entry, uncached.entry)
assert cache.snapshot()["hits"] > 0
def test_pipeline_reuses_basic_asset_filter_and_raw_scoring_features():
market = _market()
cache = MatrixComputeCache(max_bytes=64 * 1024 * 1024)
class AllEntries:
def compute_signals(self, market, params):
from app.backtest.matrix import make_signal_matrix
return make_signal_matrix(
market.shape,
entry=np.ones(market.shape, dtype=np.uint8),
)
config = MatrixPipelineConfig(
basic_filter={"enabled": True, "amount_min": 1.0},
scoring={"momentum_5d": 1.0},
order_by="score",
descending=True,
asset_mask=np.array([True, False]),
)
pipeline = MatrixStrategyPipeline()
with cache.activate(market):
first = pipeline.run(AllEntries(), market, {}, config)
second = pipeline.run(AllEntries(), market, {}, config)
np.testing.assert_array_equal(first.entry, second.entry)
operations = cache.snapshot()["operations"]
assert operations["basic_filter_mask"]["hits"] == 1
assert operations["pipeline_filter_mask"]["hits"] == 1
assert operations["matrix_feature"]["hits"] == 1
def test_pipeline_protects_strategy_working_set_when_scoring_would_overflow_cache():
from app.backtest.matrix import make_signal_matrix
market = _market()
item_bytes = market.close.nbytes
cache = MatrixComputeCache(max_bytes=item_bytes * 4)
class RollingStrategy:
def compute_signals(self, market, params):
rolling_min(market.close, 3)
rolling_max(market.close, 4)
rolling_mean(market.close, 5)
return make_signal_matrix(
market.shape,
entry=np.ones(market.shape, dtype=np.uint8),
)
config = MatrixPipelineConfig(
basic_filter={"enabled": False},
scoring={
"momentum_5d": 0.3,
"change_pct": 0.3,
"vol_ratio_5d": 0.2,
"momentum_20d": 0.2,
},
order_by="score",
descending=True,
protect_strategy_cache=True,
)
pipeline = MatrixStrategyPipeline()
with cache.activate(market):
pipeline.run(RollingStrategy(), market, {}, config)
pipeline.run(RollingStrategy(), market, {}, config)
operations = cache.snapshot()["operations"]
assert operations["rolling_min"]["hits"] == 1
assert operations["rolling_max"]["hits"] == 1
assert operations["rolling_mean"]["hits"] == 1
assert "matrix_feature" not in operations
assert "basic_filter_mask" not in operations