mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +08:00
Unify strategy execution across backtest, screener, and monitoring; isolate backtest workloads in spawn workers; and add shared matrix caching plus valid-bar indicator acceleration.
235 lines
7.9 KiB
Python
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
|