mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +08:00
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动, EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合, 可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存 - 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档), 周度调度默认关闭且永不自动发布 - 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益, 信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错) - 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复 - 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
219 lines
6.8 KiB
Python
219 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api import screener as screener_api
|
|
from app.api import strategy as strategy_api
|
|
from app.backtest.matrix import build_market_data_matrix, validate_signal_matrix
|
|
from app.backtest.optimizer import expand_param_grid
|
|
from app.backtest.strategy import StrategyDependencyResolver
|
|
from app.strategy.engine import StrategyEngine
|
|
|
|
STRATEGY_PATH = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "app"
|
|
/ "strategy"
|
|
/ "builtin"
|
|
/ "factor_rank_research.py"
|
|
)
|
|
|
|
|
|
def _market():
|
|
panel = pl.DataFrame({
|
|
"symbol": ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"] * 2,
|
|
"date": [date(2024, 1, 2)] * 4 + [date(2024, 1, 3)] * 4,
|
|
"open": [10.0] * 8,
|
|
"high": [10.5] * 8,
|
|
"low": [9.5] * 8,
|
|
"close": [10.0] * 8,
|
|
"volume": [1_000.0] * 8,
|
|
"amount": [1.0, 2.0, 3.0, 4.0, 4.0, 3.0, 2.0, 1.0],
|
|
"turnover_rate": [4.0, 3.0, 2.0, 1.0, 1.0, 2.0, 3.0, 4.0],
|
|
})
|
|
return build_market_data_matrix(
|
|
panel,
|
|
field_columns={"amount", "turnover_rate"},
|
|
)
|
|
|
|
|
|
def test_strategy_loads_as_builtin_matrix_native_and_grid_params_validate():
|
|
strategy = StrategyEngine._load_file(STRATEGY_PATH)
|
|
|
|
assert strategy.meta["id"] == "factor_rank_research"
|
|
assert strategy.meta["research_only"] is True
|
|
assert strategy.execution_backend == "matrix_native"
|
|
assert strategy.matrix_strategy is not None
|
|
assert strategy.meta["scoring"] == {}
|
|
combos = expand_param_grid(
|
|
strategy.meta["params"],
|
|
{
|
|
"entry_score": [50.0, 75.0],
|
|
"exit_score": [20.0],
|
|
"top_rank": [1, 2],
|
|
},
|
|
)
|
|
assert len(combos) == 4
|
|
assert strategy.matrix_strategy.required_warmup_bars({}) == 60
|
|
assert {"amount", "turnover_rate", "close"}.issubset(
|
|
strategy.matrix_strategy.required_fields()
|
|
)
|
|
|
|
|
|
def test_research_template_is_hidden_from_ordinary_strategy_apis(tmp_path):
|
|
engine = StrategyEngine(strategy_dirs=[STRATEGY_PATH.parent])
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
|
request = SimpleNamespace(
|
|
app=SimpleNamespace(state=SimpleNamespace(strategy_engine=engine, repo=repo))
|
|
)
|
|
|
|
screener_payload = screener_api.strategies(request)
|
|
strategy_payload = strategy_api.list_strategies(request)
|
|
|
|
assert engine.has("factor_rank_research")
|
|
assert "factor_rank_research" in {
|
|
item["id"] for item in engine.list_strategies(include_research=True)
|
|
}
|
|
assert "factor_rank_research" not in {
|
|
item["id"] for item in screener_payload["presets"]
|
|
}
|
|
assert "factor_rank_research" not in {
|
|
item["id"] for item in strategy_payload["strategies"]
|
|
}
|
|
|
|
with pytest.raises(HTTPException) as screener_error:
|
|
screener_api.run_preset(
|
|
screener_api.PresetRequest(
|
|
strategy_id="factor_rank_research",
|
|
as_of=date(2024, 1, 2),
|
|
),
|
|
request,
|
|
)
|
|
assert screener_error.value.status_code == 404
|
|
|
|
with pytest.raises(HTTPException) as strategy_error:
|
|
strategy_api.run_strategy(
|
|
strategy_api.RunRequest(
|
|
strategy_id="factor_rank_research",
|
|
as_of=date(2024, 1, 2),
|
|
),
|
|
request,
|
|
)
|
|
assert strategy_error.value.status_code == 404
|
|
|
|
|
|
def test_dependency_resolver_includes_parameter_scoring_fields():
|
|
strategy = StrategyEngine._load_file(STRATEGY_PATH)
|
|
|
|
plan = StrategyDependencyResolver().resolve(
|
|
strategy,
|
|
params={"scoring": {"amount": 1.0, "ma20_bias": 1.0}},
|
|
basic_filter={"enabled": False},
|
|
entry_signals=strategy.entry_signals,
|
|
exit_signals=strategy.exit_signals,
|
|
)
|
|
|
|
assert {"amount", "close"}.issubset(plan.base_columns)
|
|
assert plan.indicator_columns == frozenset()
|
|
assert {"amount", "close"}.issubset(plan.matrix_columns)
|
|
|
|
|
|
def test_strategy_uses_controlled_scoring_directions_thresholds_and_top_rank():
|
|
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
|
|
market = _market()
|
|
|
|
signals = strategy.compute_signals(
|
|
market,
|
|
{
|
|
"scoring": {"amount": 1.0, "turnover_rate": 1.0},
|
|
"directions": {"amount": "high", "turnover_rate": "low"},
|
|
"entry_score": 60.0,
|
|
"exit_score": 25.0,
|
|
"top_rank": 1,
|
|
},
|
|
)
|
|
|
|
validate_signal_matrix(signals, market.shape)
|
|
assert signals.entry.sum(axis=1).tolist() == [1, 1]
|
|
assert signals.entry.tolist() == [[0, 0, 0, 1], [1, 0, 0, 0]]
|
|
assert signals.exit.tolist() == [[1, 0, 0, 0], [0, 0, 0, 1]]
|
|
assert signals.entry_signal_ids == ("signal_factor_rank_entry",)
|
|
assert signals.exit_signal_ids == ("signal_factor_rank_exit",)
|
|
assert not signals.score.flags.writeable
|
|
|
|
|
|
def test_strategy_direction_changes_score_without_dynamic_formula_execution():
|
|
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
|
|
market = _market()
|
|
|
|
high = strategy.compute_signals(
|
|
market,
|
|
{
|
|
"scoring": {"amount": 1.0},
|
|
"directions": {"amount": "high"},
|
|
"entry_score": 0.0,
|
|
"exit_score": 0.0,
|
|
"top_rank": 4,
|
|
},
|
|
)
|
|
low = strategy.compute_signals(
|
|
market,
|
|
{
|
|
"scoring": {"amount": 1.0},
|
|
"directions": {"amount": "low"},
|
|
"entry_score": 0.0,
|
|
"exit_score": 0.0,
|
|
"top_rank": 4,
|
|
},
|
|
)
|
|
|
|
np.testing.assert_allclose(high.score + low.score, 100.0)
|
|
with pytest.raises(ValueError, match="unsupported matrix feature"):
|
|
strategy.compute_signals(
|
|
market,
|
|
{
|
|
"scoring": {"__import__('os').system('bad')": 1.0},
|
|
"entry_score": 50.0,
|
|
"exit_score": 20.0,
|
|
"top_rank": 1,
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("params", "message"),
|
|
[
|
|
({"scoring": {}}, "non-empty scoring"),
|
|
(
|
|
{"scoring": {f"factor_{index}": 1.0 for index in range(5)}},
|
|
"at most 4 factors",
|
|
),
|
|
(
|
|
{
|
|
"scoring": {"amount": 1.0},
|
|
"directions": {"turnover_rate": "low"},
|
|
},
|
|
"absent from scoring",
|
|
),
|
|
(
|
|
{
|
|
"scoring": {"amount": 1.0},
|
|
"entry_score": 20.0,
|
|
"exit_score": 30.0,
|
|
},
|
|
"exit_score must not exceed",
|
|
),
|
|
],
|
|
)
|
|
def test_strategy_rejects_uncontrolled_or_invalid_research_params(params, message):
|
|
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
strategy.compute_signals(_market(), params)
|