rebuild(screener): add controlled formulas and rolling backtests

This commit is contained in:
leefer
2026-07-30 10:45:40 +08:00
parent 08f69b0641
commit b79b4ba280
21 changed files with 616 additions and 37 deletions
+80 -1
View File
@@ -6,12 +6,17 @@ import pytest
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.screener.backtest import (
attach_historical_estimate,
rolling_backtest,
)
from backend.features.screener.catalog import (
CatalogError,
factor_catalog,
strategy_catalog,
validate_formula,
)
from backend.features.screener.compiler import parse_compiled_formula
from backend.features.screener.engine import execute_formula
from backend.features.screener.repository import ScreenerRepository, decode_track
from backend.features.screener.service import automatic_strategies
@@ -145,6 +150,65 @@ def test_formula_rejects_invalid_comparisons_and_duplicate_scores() -> None:
validate_formula(duplicate)
def test_natural_language_output_is_reduced_to_the_controlled_formula_schema() -> None:
compiled = parse_compiled_formula(
"""```json
{
"universe": {"exclude_st": true, "listed_days_min": 120},
"filters": [{"field": "amount_billion", "op": ">=", "value": 3}],
"score": [
{"field": "return_20d", "weight": 60, "direction": "desc"},
{"field": "sector_strength", "weight": 40, "direction": "desc"}
],
"limit": 20,
"min_score": 55,
"invented_instruction": "直接选择某只股票"
}
```"""
)
assert [item["weight"] for item in compiled["score"]] == [0.6, 0.4]
assert compiled["min_score"] == 0.55
assert "invented_instruction" not in compiled
with pytest.raises(CatalogError, match="未知筛选因子"):
parse_compiled_formula(
json.dumps(
{
**compiled,
"filters": [{"field": "future_price", "op": ">", "value": 1}],
}
)
)
def test_rolling_backtest_hides_small_samples_and_uses_t_plus_three() -> None:
formula = _formula()
snapshots = [
{
"trade_date": f"2026-07-{index + 1:02d}",
"coverage": {"market": 1},
"rows": [_row("000001.SZ", 10 + index)],
}
for index in range(23)
]
small = rolling_backtest(snapshots[:10], formula)
assert small["sample_size"] == 7
assert small["stable"] is False
assert small["win_rate"] is None
assert small["average_return_3d"] is None
stable = rolling_backtest(snapshots, formula)
assert stable["sample_size"] == 20
assert stable["stable"] is True
assert stable["win_rate"] == 100
assert stable["average_return_3d"] == pytest.approx(16.99, abs=0.01)
enriched = attach_historical_estimate(
{"items": [{"score_display": 80.0}]}, stable
)
assert enriched["items"][0]["historical_estimate"] == 93.0
def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
database = Database(tmp_path / "screener.db")
MigrationRunner(database).upgrade(MIGRATIONS)
@@ -173,6 +237,16 @@ def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
repository.save_backtest(
connection,
int(run["id"]),
{"sample_size": 7, "stable": False, "win_rate": None},
)
repository.save_backtest(
connection,
int(run["id"]),
{"sample_size": 99, "stable": True, "win_rate": 100},
)
repository.add_track(
connection,
user_id=1,
@@ -185,6 +259,11 @@ def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
assert len(repository.custom_strategies(connection, 2)) == 1
assert len(repository.tracks(connection, 1)) == 1
assert repository.tracks(connection, 2) == ()
assert repository.backtest(connection, int(run["id"])) == {
"sample_size": 7,
"stable": False,
"win_rate": None,
}
def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
@@ -219,7 +298,7 @@ def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
stored = connection.execute(
"SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],)
).fetchone()
assert len(json.loads(stored["result_json"])) == 1
assert len(json.loads(stored["result_json"])) == 1
def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None: