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
+35 -1
View File
@@ -111,7 +111,19 @@ const workspace = {
run(3, "curated", "动态多因子(基础版)", "completed", "curated-02", candidateNamed("000022", "多因子样本", "综合因子得分居前")),
],
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
custom_runs: [run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中"))],
custom_runs: [{
...run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中")),
backtest: {
sample_size: 7,
evaluated_dates: 7,
stable: false,
win_rate: null,
average_return_3d: null,
period_start: "2026-07-01",
period_end: "2026-07-09",
message: "小样本:当前7个有效样本,满20个后显示胜率与平均收益",
},
}],
};
const track = {
@@ -138,6 +150,19 @@ async function mockScreener(page) {
const body = route.request().method() === "GET" ? [track] : { id: 9 };
return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
});
await page.route("**/api/screener/formula/compile", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({
prompt_version: "screener:formula-compiler:v1",
formula: {
universe: { exclude_st: true, listed_days_min: 120 },
filters: [{ field: "amount_billion", op: ">=", value: 3 }],
score: [{ field: "return_20d", weight: 1, direction: "desc" }],
limit: 20,
min_score: 0.55,
},
}),
}));
}
test("stage 9 screening preserves deterministic modes, explicit tracking and responsive layout", async ({ page }) => {
@@ -161,6 +186,9 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await page.getByRole("button", { name: "策略选股" }).click();
await expect(page.getByText("策略库")).toBeVisible();
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
await expect(page.getByText("风险等级", { exact: true })).toBeVisible();
await expect(page.getByText("评分权重", { exact: true })).toBeVisible();
await expect(page.locator(".strategy-detail")).toContainText("20日涨幅 · 60% · 高优");
await expect(page.locator(".screener-results")).toContainText("分红样本");
await page.locator(".strategy-items").getByRole("button", { name: /动态多因子/ }).click();
await expect(page.locator(".screener-results")).toContainText("多因子样本");
@@ -173,6 +201,12 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible();
await expect(page.locator(".screener-results")).toContainText("自定义样本");
await expect(page.locator(".screener-results")).not.toContainText("多因子样本");
await expect(page.getByText(/小样本:当前7个有效样本/)).toBeVisible();
await page.getByPlaceholder(/近20日走势较强/).fill("成交额至少3亿元,偏重20日走势");
await page.getByRole("button", { name: "转换", exact: true }).click();
await expect(page.getByRole("status")).toContainText("已转换为受控公式");
await expect(page.locator(".factor-list input[type=number]").first()).toHaveValue("100");
await expect(page.locator(".filter-list input[type=number]").first()).toHaveValue("3");
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+1 -1
View File
@@ -25,7 +25,7 @@ def test_status_reads_an_existing_schema_without_mutating_history(
assert main(["status"]) == 0
assert capsys.readouterr().out.strip() == "available=true schema_version=11"
assert capsys.readouterr().out.strip() == "available=true schema_version=12"
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
+3 -1
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
assert {
"users",
"memberships",
@@ -146,9 +146,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"review_assistant_messages",
"job_runs",
"market_event_revisions",
"screener_run_backtests",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (
12,
11,
10,
9,
+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:
+48
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
import httpx
from backend.bootstrap.application import create_application
@@ -24,6 +26,23 @@ def _formula() -> dict:
}
class FormulaProvider:
def stream(self, _profile, _messages):
yield json.dumps(
{
"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,
},
ensure_ascii=False,
)
def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@@ -45,8 +64,37 @@ def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
json={"name": "越权策略", "formula": _formula()},
)
assert rejected.status_code == 403
denied_compile = await client.post(
"/api/screener/formula/compile",
headers=csrf_headers(regular),
json={"description": "选择成交活跃且趋势较强的股票"},
)
assert denied_compile.status_code == 403
use_session(client, admin)
model = await client.post(
"/api/admin/models",
headers=csrf_headers(admin),
json={
"display_name": "公式模型",
"base_url": "https://model.example.com/v1",
"model_identifier": "formula-model",
"api_key": "formula-secret",
},
)
assert model.status_code == 201
application.state.container.llm._provider = FormulaProvider()
compiled = await client.post(
"/api/screener/formula/compile",
headers=csrf_headers(admin),
json={"description": "成交额至少3亿元,偏重20日走势和板块强度"},
)
assert compiled.status_code == 200
assert [item["weight"] for item in compiled.json()["formula"]["score"]] == [
0.6,
0.4,
]
assert compiled.json()["formula"]["min_score"] == 0.55
workspace = await client.get("/api/screener?date=2026-07-30")
assert workspace.status_code == 200
assert workspace.json()["trade_date"] is None