rebuild(screener): add controlled formulas and rolling backtests
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user