mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
999 lines
33 KiB
Python
999 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import gc
|
|
from dataclasses import replace
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.backtest import matrix as matrix_module
|
|
from app.backtest.matrix import (
|
|
MatrixPipelineConfig,
|
|
MatrixStrategyPipeline,
|
|
RealtimeMarketDataMatrix,
|
|
apply_time_masks,
|
|
build_market_data_matrix,
|
|
build_matrix_score,
|
|
load_market_data_matrix_from_parquet,
|
|
make_signal_matrix,
|
|
matrix_feature,
|
|
slice_signal_matrix,
|
|
validate_signal_matrix,
|
|
)
|
|
from app.backtest.strategy import build_matrix_cache_profile
|
|
from app.indicators.pipeline import (
|
|
compute_indicators,
|
|
compute_limit_signals,
|
|
)
|
|
from app.indicators.pipeline import (
|
|
compute_signals as compute_indicator_signals,
|
|
)
|
|
from app.strategy.engine import StrategyDataContext, StrategyEngine
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def test_common_matrix_features_match_polars_indicator_pipeline():
|
|
rows = []
|
|
start = date(2024, 1, 1)
|
|
for offset in range(100):
|
|
close = 10.0 + offset * 0.03 + np.sin(offset / 4.0) * 0.8
|
|
rows.append({
|
|
"symbol": "000001.SZ",
|
|
"date": start + timedelta(days=offset),
|
|
"open": close - 0.1,
|
|
"high": close + 0.3 + (offset % 3) * 0.02,
|
|
"low": close - 0.25,
|
|
"close": close,
|
|
"volume": 1000.0 + (offset % 7) * 130.0,
|
|
})
|
|
panel = pl.DataFrame(rows)
|
|
features = {
|
|
"prev_close", "change_pct", "change_amount", "amplitude",
|
|
"ma5", "ma20", "ma60", "boll_upper", "boll_lower",
|
|
"high_60d", "low_60d", "momentum_60d", "vol_ratio_5d",
|
|
"annual_vol_20d", "rsi_14",
|
|
}
|
|
enriched = compute_indicators(panel, needed=features)
|
|
market = build_market_data_matrix(panel)
|
|
|
|
for name in sorted(features):
|
|
expected = enriched.sort(["date", "symbol"])[name].to_numpy()
|
|
actual = matrix_feature(market, name)[:, 0]
|
|
np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5, equal_nan=True)
|
|
|
|
expected_bias = (
|
|
enriched.sort(["date", "symbol"])["close"].to_numpy()
|
|
/ enriched.sort(["date", "symbol"])["ma20"].to_numpy()
|
|
- 1.0
|
|
)
|
|
np.testing.assert_allclose(
|
|
matrix_feature(market, "ma20_bias")[:, 0],
|
|
expected_bias,
|
|
rtol=2e-5,
|
|
atol=2e-5,
|
|
equal_nan=True,
|
|
)
|
|
|
|
|
|
def _panel_with_missing_asset_bar() -> pl.DataFrame:
|
|
rows = []
|
|
start = date(2024, 1, 1)
|
|
for offset in range(110):
|
|
for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")):
|
|
if symbol == "000001.SZ" and offset == 43:
|
|
continue
|
|
if asset_id == 0:
|
|
close = (
|
|
20.0 - offset * 0.15
|
|
if offset < 35
|
|
else 14.75 + (offset - 35) * 0.25
|
|
)
|
|
else:
|
|
close = 18.0 + offset * 0.025 + np.sin(offset / 5.0) * 0.7
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": start + timedelta(days=offset),
|
|
"open": close - 0.1,
|
|
"high": close + 0.3,
|
|
"low": close - 0.25,
|
|
"close": close,
|
|
"volume": 1000.0 + asset_id * 200.0 + (offset % 7) * 130.0,
|
|
})
|
|
return pl.DataFrame(rows)
|
|
|
|
|
|
def test_matrix_features_skip_missing_asset_bars_like_polars_groups():
|
|
panel = _panel_with_missing_asset_bar()
|
|
features = {
|
|
"prev_close", "change_pct", "change_amount", "amplitude",
|
|
"ma5", "ma20", "ma60", "boll_upper", "boll_lower",
|
|
"high_60d", "low_60d", "momentum_60d", "vol_ratio_5d",
|
|
"annual_vol_20d", "rsi_14",
|
|
}
|
|
enriched = compute_indicators(panel, needed=features)
|
|
market = build_market_data_matrix(panel)
|
|
time_id_by_date = {
|
|
label[:10]: time_id
|
|
for time_id, label in enumerate(market.timestamp_labels)
|
|
}
|
|
|
|
for symbol in market.symbols:
|
|
expected_rows = enriched.filter(pl.col("symbol") == symbol).sort("date")
|
|
time_ids = np.array(
|
|
[time_id_by_date[str(value)] for value in expected_rows["date"]],
|
|
dtype=np.intp,
|
|
)
|
|
asset_id = market.symbols.index(symbol)
|
|
for name in sorted(features):
|
|
expected = expected_rows[name].to_numpy()
|
|
actual = matrix_feature(market, name)[time_ids, asset_id]
|
|
np.testing.assert_allclose(
|
|
actual,
|
|
expected,
|
|
rtol=2e-5,
|
|
atol=2e-5,
|
|
equal_nan=True,
|
|
err_msg=f"{symbol} {name}",
|
|
)
|
|
|
|
missing_time_id = time_id_by_date["2024-02-13"]
|
|
missing_asset_id = market.symbols.index("000001.SZ")
|
|
for name in features:
|
|
assert np.isnan(matrix_feature(market, name)[missing_time_id, missing_asset_id])
|
|
|
|
|
|
def test_matrix_pipeline_builds_and_reuses_one_compact_valid_bar_index():
|
|
panel = _panel_with_missing_asset_bar()
|
|
market = build_market_data_matrix(panel)
|
|
base_bytes = market.nbytes
|
|
strategy = StrategyEngine._load_file(
|
|
REPO_ROOT / "backend" / "app" / "strategy" / "builtin" / "ma_golden_cross.py"
|
|
).matrix_strategy
|
|
|
|
with patch.object(
|
|
matrix_module,
|
|
"_build_valid_bar_index",
|
|
wraps=matrix_module._build_valid_bar_index,
|
|
) as build_index:
|
|
MatrixStrategyPipeline().run(
|
|
strategy,
|
|
market,
|
|
{
|
|
"require_ma_golden": True,
|
|
"use_volume_filter": False,
|
|
"require_above_ma60": False,
|
|
},
|
|
MatrixPipelineConfig(
|
|
basic_filter={"enabled": False},
|
|
scoring={},
|
|
order_by=None,
|
|
descending=True,
|
|
),
|
|
)
|
|
|
|
index = market.valid_bars
|
|
assert build_index.call_count == 1
|
|
assert index.rows.size == panel.height
|
|
assert index.offsets.tolist() == [0, 109, 219]
|
|
assert market.valid_bars is index
|
|
assert market.nbytes == base_bytes + index.nbytes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("strategy_file", "entry_column", "exit_column", "params"),
|
|
[
|
|
(
|
|
"ma_golden_cross.py",
|
|
"signal_ma_golden_5_20",
|
|
"signal_ma_dead_5_20",
|
|
{
|
|
"require_ma_golden": True,
|
|
"use_volume_filter": False,
|
|
"require_above_ma60": False,
|
|
},
|
|
),
|
|
(
|
|
"macd_golden.py",
|
|
"signal_macd_golden",
|
|
"signal_macd_dead",
|
|
{"require_macd_golden": True, "use_volume_filter": False},
|
|
),
|
|
],
|
|
)
|
|
def test_matrix_crossovers_skip_missing_asset_bars_like_polars_signals(
|
|
strategy_file: str,
|
|
entry_column: str,
|
|
exit_column: str,
|
|
params: dict,
|
|
):
|
|
panel = _panel_with_missing_asset_bar()
|
|
indicator_names = (
|
|
{"ma5", "ma20"}
|
|
if strategy_file == "ma_golden_cross.py"
|
|
else {"macd_dif", "macd_dea"}
|
|
)
|
|
enriched = compute_indicators(panel, needed=indicator_names)
|
|
expected = compute_indicator_signals(
|
|
enriched,
|
|
needed={entry_column, exit_column},
|
|
)
|
|
market = build_market_data_matrix(panel)
|
|
strategy_def = StrategyEngine._load_file(
|
|
REPO_ROOT / "backend" / "app" / "strategy" / "builtin" / strategy_file
|
|
)
|
|
actual = strategy_def.matrix_strategy.compute_signals(market, params)
|
|
time_id_by_date = {
|
|
label[:10]: time_id
|
|
for time_id, label in enumerate(market.timestamp_labels)
|
|
}
|
|
|
|
for symbol in market.symbols:
|
|
expected_rows = expected.filter(pl.col("symbol") == symbol).sort("date")
|
|
time_ids = np.array(
|
|
[time_id_by_date[str(value)] for value in expected_rows["date"]],
|
|
dtype=np.intp,
|
|
)
|
|
asset_id = market.symbols.index(symbol)
|
|
expected_entry = (
|
|
expected_rows[entry_column].fill_null(False).cast(pl.UInt8).to_numpy()
|
|
)
|
|
expected_exit = (
|
|
expected_rows[exit_column].fill_null(False).cast(pl.UInt8).to_numpy()
|
|
)
|
|
np.testing.assert_array_equal(actual.entry[time_ids, asset_id], expected_entry)
|
|
np.testing.assert_array_equal(actual.exit[time_ids, asset_id], expected_exit)
|
|
|
|
missing_time_id = time_id_by_date["2024-02-13"]
|
|
missing_asset_id = market.symbols.index("000001.SZ")
|
|
assert actual.entry[missing_time_id, missing_asset_id] == 0
|
|
assert actual.exit[missing_time_id, missing_asset_id] == 0
|
|
assert actual.entry.any() or actual.exit.any()
|
|
|
|
|
|
def test_builtin_matrix_strategies_use_their_declared_formula_modules():
|
|
strategy_dir = REPO_ROOT / "backend" / "app" / "strategy" / "builtin"
|
|
strategy_files = sorted(
|
|
path for path in strategy_dir.glob("*.py") if path.name != "__init__.py"
|
|
)
|
|
|
|
assert len(strategy_files) == 18
|
|
for strategy_path in strategy_files:
|
|
strategy = StrategyEngine._load_file(strategy_path)
|
|
assert strategy.execution_backend == "matrix_native"
|
|
assert strategy.matrix_strategy is not None
|
|
assert strategy.matrix_strategy.__class__.__module__ == strategy_path.stem
|
|
assert strategy.filter_fn is None
|
|
assert strategy.filter_history_fn is None
|
|
|
|
|
|
def test_market_matrix_derives_live_raw_close_when_requested():
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "600000.SH"],
|
|
"date": [date(2024, 1, 1)] * 2,
|
|
"open": [10.0, 20.0],
|
|
"high": [10.2, 20.2],
|
|
"low": [9.8, 19.8],
|
|
"close": [10.1, 20.1],
|
|
"volume": [1_000.0, 2_000.0],
|
|
})
|
|
market = build_market_data_matrix(panel, field_columns={"raw_close"})
|
|
|
|
np.testing.assert_array_equal(market.field("raw_close"), market.close)
|
|
|
|
live = RealtimeMarketDataMatrix(
|
|
panel,
|
|
field_columns={"raw_close"},
|
|
)
|
|
live.update(panel.with_columns(pl.lit(date(2024, 1, 2)).alias("date")))
|
|
snapshot = live.snapshot()
|
|
np.testing.assert_array_equal(snapshot.field("raw_close")[-1], snapshot.close[-1])
|
|
|
|
|
|
def test_direct_parquet_matrix_reports_actionable_error_when_enriched_is_empty(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
market_root.mkdir()
|
|
|
|
with pytest.raises(ValueError, match="本地指标数据为空"):
|
|
load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
date(2024, 1, 1),
|
|
date(2024, 1, 31),
|
|
field_columns=set(),
|
|
)
|
|
|
|
|
|
def test_direct_parquet_matrix_matches_panel_builder_and_reuses_mmap(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
days = (date(2024, 1, 2), date(2024, 1, 3), date(2024, 1, 4))
|
|
rows = []
|
|
closes = {
|
|
"000001.SZ": (10.0, 11.0, 12.0),
|
|
"000002.SZ": (10.0, 10.5, None),
|
|
"300001.SZ": (10.0, 12.0, 12.2),
|
|
}
|
|
for symbol, values in closes.items():
|
|
for current, close in zip(days, values, strict=True):
|
|
if close is None:
|
|
continue
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": current,
|
|
"open": close,
|
|
"high": close,
|
|
"low": close,
|
|
"close": close,
|
|
"volume": 1_000.0,
|
|
"amount": close * 100_000.0,
|
|
"raw_close": close,
|
|
"raw_high": close,
|
|
"raw_low": close,
|
|
"turnover_rate": 1.5,
|
|
})
|
|
panel = pl.DataFrame(rows).sort(["symbol", "date"])
|
|
for current in days:
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
panel.filter(pl.col("date") == current).write_parquet(partition / "part.parquet")
|
|
|
|
instruments = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "000002.SZ", "300001.SZ"],
|
|
"name": ["普通", "ST测试", "创业板"],
|
|
"total_shares": [1_000_000.0, 2_000_000.0, 3_000_000.0],
|
|
"float_shares": [800_000.0, 1_500_000.0, 2_000_000.0],
|
|
"limit_up": [12.0, 11.0, 13.0],
|
|
"limit_down": [10.0, 9.0, 10.0],
|
|
})
|
|
enriched = compute_limit_signals(
|
|
panel,
|
|
instruments,
|
|
needed={"signal_limit_up", "signal_limit_down"},
|
|
).join(
|
|
instruments.select("symbol", "name", "total_shares", "float_shares"),
|
|
on="symbol",
|
|
how="left",
|
|
)
|
|
field_columns = {
|
|
"amount",
|
|
"raw_close",
|
|
"raw_high",
|
|
"raw_low",
|
|
"turnover_rate",
|
|
"total_shares",
|
|
"float_shares",
|
|
}
|
|
expected = build_market_data_matrix(enriched, field_columns=field_columns)
|
|
cache_root = tmp_path / "matrix_cache"
|
|
actual = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[0],
|
|
days[-1],
|
|
field_columns=field_columns,
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
assert actual.cache_status == "built"
|
|
|
|
assert actual.timestamp_labels == expected.timestamp_labels
|
|
assert actual.symbols == expected.symbols
|
|
assert actual.names == expected.names
|
|
for name in (
|
|
"timestamps",
|
|
"session_ids",
|
|
"open",
|
|
"high",
|
|
"low",
|
|
"close",
|
|
"volume",
|
|
"tradable",
|
|
"limit_up_locked",
|
|
"limit_down_locked",
|
|
):
|
|
np.testing.assert_array_equal(getattr(actual, name), getattr(expected, name))
|
|
for name in field_columns:
|
|
np.testing.assert_array_equal(actual.field(name), expected.field(name))
|
|
|
|
cached = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[0],
|
|
days[-1],
|
|
field_columns=field_columns,
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
assert cached.cache_status == "exact"
|
|
assert isinstance(cached.close, np.memmap)
|
|
assert not cached.close.flags.writeable
|
|
np.testing.assert_array_equal(cached.close, actual.close)
|
|
|
|
latest_path = market_root / f"date={days[-1].isoformat()}" / "part.parquet"
|
|
latest = pl.read_parquet(latest_path).with_columns(
|
|
pl.when(pl.col("symbol") == "000001.SZ")
|
|
.then(12.5)
|
|
.otherwise(pl.col("close"))
|
|
.alias("close")
|
|
)
|
|
latest.write_parquet(latest_path)
|
|
refreshed = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[0],
|
|
days[-1],
|
|
field_columns=field_columns,
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
target_asset = refreshed.symbols.index("000001.SZ")
|
|
assert refreshed.close[-1, target_asset] == pytest.approx(12.5)
|
|
assert len(list(cache_root.glob("v*-*"))) == 2
|
|
|
|
|
|
def test_covering_matrix_cache_reuses_wider_dates_and_fields(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
days = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(4)]
|
|
rows = []
|
|
for current in days:
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")):
|
|
close = 10.0 + asset_id + (current - days[0]).days
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": current,
|
|
"open": close,
|
|
"high": close,
|
|
"low": close,
|
|
"close": close,
|
|
"volume": 1_000.0,
|
|
"amount": close * 100_000.0,
|
|
"raw_close": close,
|
|
})
|
|
pl.DataFrame([row for row in rows if row["date"] == current]).write_parquet(
|
|
partition / "part.parquet"
|
|
)
|
|
instruments = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "600000.SH"],
|
|
"name": ["A", "B"],
|
|
})
|
|
cache_root = tmp_path / "matrix_cache"
|
|
broad = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[0],
|
|
days[-1],
|
|
field_columns={"amount", "raw_close"},
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
narrow = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[1],
|
|
days[2],
|
|
field_columns={"raw_close"},
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
|
|
assert broad.cache_status == "built"
|
|
assert narrow.cache_status == "covering"
|
|
assert narrow.cache_path == broad.cache_path
|
|
assert narrow.timestamp_labels == tuple(value.isoformat() for value in days[1:3])
|
|
assert set(narrow.fields) == {"raw_close"}
|
|
assert isinstance(narrow.close, np.memmap)
|
|
assert not narrow.close.flags.writeable
|
|
assert len(list(cache_root.glob("v*-*"))) == 1
|
|
|
|
|
|
def test_covering_cache_ignores_outside_slice_change_but_invalidates_inside(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
days = [date(2024, 2, 1) + timedelta(days=offset) for offset in range(4)]
|
|
for offset, current in enumerate(days):
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"date": [current],
|
|
"open": [10.0 + offset],
|
|
"high": [10.0 + offset],
|
|
"low": [10.0 + offset],
|
|
"close": [10.0 + offset],
|
|
"volume": [1_000.0],
|
|
}).write_parquet(partition / "part.parquet")
|
|
instruments = pl.DataFrame({"symbol": ["000001.SZ"], "name": ["A"]})
|
|
cache_root = tmp_path / "matrix_cache"
|
|
load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[0],
|
|
days[-1],
|
|
field_columns=set(),
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
|
|
outside_path = market_root / f"date={days[-1].isoformat()}" / "part.parquet"
|
|
pl.read_parquet(outside_path).with_columns(pl.lit(99.0).alias("close")).write_parquet(
|
|
outside_path
|
|
)
|
|
outside = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[1],
|
|
days[2],
|
|
field_columns=set(),
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
assert outside.cache_status == "covering"
|
|
|
|
inside_path = market_root / f"date={days[2].isoformat()}" / "part.parquet"
|
|
pl.read_parquet(inside_path).with_columns(pl.lit(77.0).alias("close")).write_parquet(
|
|
inside_path
|
|
)
|
|
inside = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
days[1],
|
|
days[2],
|
|
field_columns=set(),
|
|
instruments=instruments,
|
|
cache_root=cache_root,
|
|
)
|
|
assert inside.cache_status == "built"
|
|
assert inside.close[-1, 0] == pytest.approx(77.0)
|
|
|
|
|
|
def test_matrix_cache_can_be_disabled(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
current = date(2024, 3, 1)
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"date": [current],
|
|
"open": [10.0],
|
|
"high": [10.0],
|
|
"low": [10.0],
|
|
"close": [10.0],
|
|
"volume": [1_000.0],
|
|
}).write_parquet(partition / "part.parquet")
|
|
|
|
market = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=None,
|
|
)
|
|
assert market.cache_status == "disabled"
|
|
assert not isinstance(market.close, np.memmap)
|
|
|
|
|
|
def test_matrix_cache_prunes_by_bytes_and_leaves_no_staging_directory(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
current = date(2024, 3, 4)
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
path = partition / "part.parquet"
|
|
|
|
def write_close(value: float) -> None:
|
|
pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"date": [current],
|
|
"open": [value],
|
|
"high": [value],
|
|
"low": [value],
|
|
"close": [value],
|
|
"volume": [1_000.0],
|
|
}).write_parquet(path)
|
|
|
|
cache_root = tmp_path / "matrix_cache"
|
|
write_close(10.0)
|
|
first = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=cache_root,
|
|
cache_max_bytes=1,
|
|
)
|
|
write_close(11.0)
|
|
second = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=cache_root,
|
|
cache_max_bytes=1,
|
|
)
|
|
|
|
assert first.cache_path != second.cache_path
|
|
del first
|
|
gc.collect()
|
|
assert second.close[0, 0] == pytest.approx(11.0)
|
|
assert len(list(cache_root.glob("v4-*"))) == 1
|
|
assert list(cache_root.glob(".*.tmp")) == []
|
|
assert len(list(cache_root.glob(".axes-v1-*.json"))) == 1
|
|
|
|
|
|
def test_managed_source_generation_skips_file_walk_and_invalidates_explicitly(tmp_path):
|
|
market_root = tmp_path / "kline_daily_enriched"
|
|
current = date(2024, 3, 5)
|
|
partition = market_root / f"date={current.isoformat()}"
|
|
partition.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"date": [current],
|
|
"open": [10.0],
|
|
"high": [10.0],
|
|
"low": [10.0],
|
|
"close": [10.0],
|
|
"volume": [1_000.0],
|
|
}).write_parquet(partition / "part.parquet")
|
|
cache_root = tmp_path / "matrix_cache"
|
|
|
|
first = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=cache_root,
|
|
source_generation="generation-a",
|
|
)
|
|
repeated = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=cache_root,
|
|
source_generation="generation-a",
|
|
)
|
|
changed = load_market_data_matrix_from_parquet(
|
|
market_root,
|
|
current,
|
|
current,
|
|
field_columns=set(),
|
|
cache_root=cache_root,
|
|
source_generation="generation-b",
|
|
)
|
|
|
|
assert first.cache_status == "built"
|
|
assert repeated.cache_status == "exact"
|
|
assert repeated.cache_path == first.cache_path
|
|
assert changed.cache_status == "built"
|
|
assert changed.cache_path != first.cache_path
|
|
del first, repeated
|
|
gc.collect()
|
|
assert len(list(cache_root.glob("v4-*"))) == 1
|
|
|
|
|
|
def test_registered_builtin_matrix_strategies_share_one_cache_profile():
|
|
engine = StrategyEngine(
|
|
strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"]
|
|
)
|
|
profile = build_matrix_cache_profile(engine, "stock")
|
|
strategies = engine.strategy_definitions()
|
|
|
|
assert len(strategies) == 18
|
|
assert all(strategy.execution_backend == "matrix_native" for strategy in strategies)
|
|
assert profile.warmup_bars > 0
|
|
assert profile.forward_bars == max(int(strategy.max_hold_days or 0) for strategy in strategies)
|
|
assert {"open", "high", "low", "close", "volume"}.issubset(profile.field_columns)
|
|
|
|
|
|
def test_chunked_matrix_score_matches_previous_full_matrix_formula():
|
|
row_count = 4
|
|
asset_count = 600
|
|
symbols = [f"{asset_id:06d}.SZ" for asset_id in range(asset_count)]
|
|
rows = []
|
|
rng = np.random.default_rng(20260715)
|
|
for time_id in range(row_count):
|
|
for asset_id, symbol in enumerate(symbols):
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": date(2024, 1, 1) + timedelta(days=time_id),
|
|
"open": 10.0,
|
|
"high": 10.0,
|
|
"low": 10.0,
|
|
"close": 10.0,
|
|
"volume": 1_000.0,
|
|
"feature_a": float(rng.normal()),
|
|
"feature_b": float((asset_id % 7) - 3),
|
|
})
|
|
market = build_market_data_matrix(
|
|
pl.DataFrame(rows),
|
|
field_columns={"feature_a", "feature_b"},
|
|
)
|
|
universe = rng.random(market.shape) > 0.2
|
|
weights = {"feature_a": 0.75, "feature_b": 0.25}
|
|
|
|
expected = np.zeros(market.shape, dtype=np.float32)
|
|
all_finite = universe.copy()
|
|
total_weight = sum(weights.values())
|
|
for name, weight in weights.items():
|
|
values = market.field(name)
|
|
finite = universe & np.isfinite(values)
|
|
all_finite &= np.isfinite(values)
|
|
row_min = np.min(np.where(finite, values, np.inf), axis=1)
|
|
row_max = np.max(np.where(finite, values, -np.inf), axis=1)
|
|
row_range = row_max - row_min
|
|
normalized = np.zeros(market.shape, dtype=np.float32)
|
|
varying = finite & np.isfinite(row_range[:, None]) & (row_range[:, None] > 0)
|
|
equal = finite & ~varying
|
|
np.divide(
|
|
values - row_min[:, None],
|
|
row_range[:, None],
|
|
out=normalized,
|
|
where=varying,
|
|
)
|
|
normalized[equal] = np.float32(0.5)
|
|
expected += normalized * np.float32(weight / total_weight)
|
|
expected *= np.float32(100.0)
|
|
expected[~universe | ~all_finite] = 0.0
|
|
|
|
actual = build_matrix_score(
|
|
market,
|
|
universe,
|
|
weights,
|
|
"score",
|
|
True,
|
|
fallback=np.zeros(market.shape, dtype=np.float32),
|
|
)
|
|
np.testing.assert_array_equal(actual, expected)
|
|
|
|
|
|
def test_signal_slice_is_zero_copy_and_masking_only_allocates_final_flags():
|
|
entry = np.ones((5, 2), dtype=np.uint8)
|
|
codes = np.arange(10, dtype=np.int16).reshape(5, 2)
|
|
score = np.arange(10, dtype=np.float32).reshape(5, 2)
|
|
signals = make_signal_matrix(
|
|
(5, 2),
|
|
entry=entry,
|
|
exit=entry,
|
|
score=score,
|
|
entry_signal_code=codes,
|
|
exit_signal_code=codes,
|
|
)
|
|
sliced = slice_signal_matrix(signals, 1, 4)
|
|
assert np.shares_memory(sliced.entry, signals.entry)
|
|
assert np.shares_memory(sliced.score, signals.score)
|
|
|
|
masked = apply_time_masks(
|
|
sliced,
|
|
np.array([True, False, True]),
|
|
np.array([False, True, True]),
|
|
)
|
|
assert np.shares_memory(masked.score, signals.score)
|
|
assert masked.entry.tolist() == [[1, 1], [0, 0], [1, 1]]
|
|
assert masked.exit.tolist() == [[0, 0], [1, 1], [1, 1]]
|
|
assert masked.entry_signal_code[1].tolist() == [-1, -1]
|
|
assert masked.exit_signal_code[0].tolist() == [-1, -1]
|
|
assert not masked.entry.flags.writeable
|
|
assert signals.entry.tolist() == [[1, 1]] * 5
|
|
|
|
|
|
def test_matrix_pipeline_applies_basic_filter_and_candidate_scoring():
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "600000.SH"],
|
|
"name": ["A", "B"],
|
|
"date": [date(2024, 1, 1)] * 2,
|
|
"open": [10.0, 20.0],
|
|
"high": [10.0, 20.0],
|
|
"low": [10.0, 20.0],
|
|
"close": [10.0, 20.0],
|
|
"volume": [100.0, 100.0],
|
|
"amount": [50.0, 500.0],
|
|
})
|
|
market = build_market_data_matrix(panel, field_columns={"amount"})
|
|
|
|
class AllEntries:
|
|
def required_fields(self):
|
|
return frozenset({"close"})
|
|
|
|
def required_warmup_bars(self, params):
|
|
return 1
|
|
|
|
def compute_signals(self, market, params):
|
|
return make_signal_matrix(market.shape, entry=np.ones(market.shape, dtype=np.uint8))
|
|
|
|
signals = MatrixStrategyPipeline().run(
|
|
AllEntries(),
|
|
market,
|
|
{},
|
|
MatrixPipelineConfig(
|
|
basic_filter={"enabled": True, "amount_min": 100.0},
|
|
scoring={"close": 1.0},
|
|
order_by="score",
|
|
descending=True,
|
|
),
|
|
)
|
|
|
|
assert signals.entry.tolist() == [[0, 1]]
|
|
assert signals.score.tolist() == [[0.0, 50.0]]
|
|
|
|
|
|
def test_signal_matrix_validation_rejects_mutable_strategy_output():
|
|
signals = make_signal_matrix((2, 1))
|
|
mutable_entry = signals.entry.copy()
|
|
invalid = type(signals)(
|
|
entry=mutable_entry,
|
|
exit=signals.exit,
|
|
score=signals.score,
|
|
entry_signal_code=signals.entry_signal_code,
|
|
exit_signal_code=signals.exit_signal_code,
|
|
)
|
|
with pytest.raises(ValueError, match="read-only"):
|
|
validate_signal_matrix(invalid, (2, 1))
|
|
|
|
|
|
def test_matrix_pipeline_applies_asset_pool_before_cross_sectional_scoring():
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "600000.SH"],
|
|
"name": ["A", "B"],
|
|
"date": [date(2024, 1, 1)] * 2,
|
|
"open": [10.0, 20.0],
|
|
"high": [10.0, 20.0],
|
|
"low": [10.0, 20.0],
|
|
"close": [10.0, 20.0],
|
|
"volume": [100.0, 100.0],
|
|
})
|
|
market = build_market_data_matrix(panel)
|
|
|
|
class AllEntries:
|
|
def required_fields(self):
|
|
return frozenset({"close"})
|
|
|
|
def required_warmup_bars(self, params):
|
|
return 1
|
|
|
|
def compute_signals(self, market, params):
|
|
return make_signal_matrix(market.shape, entry=np.ones(market.shape, dtype=np.uint8))
|
|
|
|
signals = MatrixStrategyPipeline().run(
|
|
AllEntries(),
|
|
market,
|
|
{},
|
|
MatrixPipelineConfig(
|
|
basic_filter={"enabled": False},
|
|
scoring={"close": 1.0},
|
|
order_by="score",
|
|
descending=True,
|
|
asset_mask=np.array([False, True]),
|
|
),
|
|
)
|
|
|
|
assert signals.entry.tolist() == [[0, 1]]
|
|
assert signals.score.tolist() == [[0.0, 50.0]]
|
|
|
|
|
|
def test_realtime_market_matrix_overwrites_last_row_and_appends_new_bar():
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "600000.SH"] * 2,
|
|
"name": ["A", "B"] * 2,
|
|
"date": [date(2024, 1, 1)] * 2 + [date(2024, 1, 2)] * 2,
|
|
"open": [10.0, 20.0, 10.5, 20.5],
|
|
"high": [10.2, 20.2, 10.7, 20.7],
|
|
"low": [9.8, 19.8, 10.3, 20.3],
|
|
"close": [10.1, 20.1, 10.6, 20.6],
|
|
"volume": [1_000.0, 2_000.0, 1_100.0, 2_100.0],
|
|
"amount": [10_100.0, 40_200.0, 11_660.0, 43_260.0],
|
|
})
|
|
buffer = RealtimeMarketDataMatrix(panel, field_columns={"amount"})
|
|
|
|
same_bar = panel.filter(pl.col("date") == date(2024, 1, 2)).with_columns(
|
|
(pl.col("close") + 5.0).alias("close")
|
|
)
|
|
buffer.update(same_bar)
|
|
snapshot = buffer.snapshot()
|
|
assert snapshot.shape == (2, 2)
|
|
assert snapshot.close[-1].tolist() == pytest.approx(same_bar.sort("symbol")["close"].to_list())
|
|
assert buffer.build_count == 1
|
|
assert buffer.update_count == 1
|
|
assert not snapshot.close.flags.writeable
|
|
|
|
next_bar = same_bar.with_columns(pl.lit(date(2024, 1, 3)).alias("date"))
|
|
buffer.update(next_bar)
|
|
assert buffer.snapshot().shape == (3, 2)
|
|
assert buffer.build_count == 1
|
|
assert buffer.update_count == 2
|
|
|
|
|
|
def test_strategy_engine_runs_matrix_strategy_without_legacy_filter():
|
|
start = date(2024, 1, 1)
|
|
rows = []
|
|
for offset in range(65):
|
|
values = (
|
|
("000001.SZ", 10.0 + offset * 0.05),
|
|
("600000.SH", 20.0 + offset * 0.03),
|
|
)
|
|
for symbol, close in values:
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"name": symbol,
|
|
"date": start + timedelta(days=offset),
|
|
"open": close,
|
|
"high": close * 1.01,
|
|
"low": close * 0.99,
|
|
"close": close,
|
|
"volume": 1_000_000.0,
|
|
"amount": 100_000_000.0,
|
|
"total_shares": 1_000_000_000.0,
|
|
"float_shares": 800_000_000.0,
|
|
})
|
|
history = pl.DataFrame(rows)
|
|
target = start + timedelta(days=64)
|
|
engine = StrategyEngine(
|
|
strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"],
|
|
)
|
|
strategy = engine.get("macd_golden")
|
|
assert strategy.filter_fn is None
|
|
|
|
result = engine.run(
|
|
"macd_golden",
|
|
StrategyDataContext(
|
|
asset_type="stock",
|
|
timeframe="1d",
|
|
as_of=target,
|
|
current=history.filter(pl.col("date") == target),
|
|
history=history,
|
|
),
|
|
pool=["600000.SH"],
|
|
params={"require_macd_golden": False, "use_volume_filter": False},
|
|
overrides={"basic_filter": {"enabled": False}},
|
|
)
|
|
|
|
assert [row["symbol"] for row in result.rows] == ["600000.SH"]
|
|
assert result.total == 1
|
|
|
|
|
|
def test_strategy_engine_run_all_builds_one_shared_matrix():
|
|
start = date(2024, 1, 1)
|
|
rows = []
|
|
for offset in range(65):
|
|
close = 10.0 + offset * 0.05
|
|
rows.append({
|
|
"symbol": "000001.SZ",
|
|
"name": "A",
|
|
"date": start + timedelta(days=offset),
|
|
"open": close,
|
|
"high": close * 1.01,
|
|
"low": close * 0.99,
|
|
"close": close,
|
|
"volume": 1_000_000.0,
|
|
"amount": 100_000_000.0,
|
|
"total_shares": 1_000_000_000.0,
|
|
"float_shares": 800_000_000.0,
|
|
})
|
|
history = pl.DataFrame(rows)
|
|
target = start + timedelta(days=64)
|
|
engine = StrategyEngine(
|
|
strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"],
|
|
)
|
|
original = engine.get("macd_golden")
|
|
engine._strategies["macd_copy"] = replace(
|
|
original,
|
|
meta={**original.meta, "id": "macd_copy"},
|
|
)
|
|
params = {"require_macd_golden": False, "use_volume_filter": False}
|
|
overrides = {"basic_filter": {"enabled": False}}
|
|
|
|
with patch(
|
|
"app.backtest.matrix.build_market_data_matrix",
|
|
wraps=matrix_module.build_market_data_matrix,
|
|
) as build:
|
|
results = engine.run_all(
|
|
StrategyDataContext(
|
|
asset_type="stock",
|
|
timeframe="1d",
|
|
as_of=target,
|
|
current=history.filter(pl.col("date") == target),
|
|
history=history,
|
|
),
|
|
strategy_ids=["macd_golden", "macd_copy"],
|
|
params_map={"macd_golden": params, "macd_copy": params},
|
|
overrides_map={"macd_golden": overrides, "macd_copy": overrides},
|
|
)
|
|
|
|
assert build.call_count == 1
|
|
assert results["macd_golden"].total == 1
|
|
assert results["macd_copy"].total == 1
|