Files
lytem28 b6cf0495e1 feat: complete matrix-native backtest engine
Unify strategy execution across backtest, screener, and monitoring; isolate backtest workloads in spawn workers; and add shared matrix caching plus valid-bar indicator acceleration.
2026-07-16 12:17:27 +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" / "app" / "strategy" / "builtin" / "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