rebuild(stage-9): deliver deterministic intelligent screening

This commit is contained in:
leefer
2026-07-30 05:15:17 +08:00
parent 6cb52e864a
commit 158257ebb8
46 changed files with 7322 additions and 34 deletions
+172
View File
@@ -0,0 +1,172 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-9");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page, username, password) {
await page.goto("/");
await page.getByLabel("账号名").fill(username);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
const formula = {
universe: { exclude_st: true, listed_days_min: 120 },
filters: [{ field: "amount_billion", op: ">=", value: 1 }],
score: [
{ field: "return_20d", weight: 0.6, direction: "desc" },
{ field: "sector_strength", weight: 0.4, direction: "desc" },
],
limit: 30,
min_score: 0.5,
meta: {
category: "趋势追踪",
suitable_environment: "趋势明确且成交活跃的市场",
failure_risk: "震荡轮动过快时信号容易反复",
},
};
const stageStrategy = {
id: "stage-ice",
version: 1,
kind: "stage",
name: "冰点抗跌先手",
display_name: "冰点抗跌先手",
description: "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。",
regimes: ["ice"],
formula,
};
const curated = [
{ ...stageStrategy, id: "curated-01", kind: "curated", name: "连续分红质量", display_name: "连续分红质量" },
{ ...stageStrategy, id: "curated-02", kind: "curated", name: "动态多因子(基础版)", display_name: "动态多因子(基础版)", formula: { ...formula, meta: { ...formula.meta, category: "多因子" } } },
];
const candidate = {
identifier: "000001.SZ",
code: "000001",
name: "平安银行",
sector: "银行",
close: 12.35,
pct_chg: 2.4,
amount_billion: 18.6,
score_display: 86.5,
reason: "中期动量与行业强度居前",
risk_flags: [],
};
function run(id, mode, name, status = "completed") {
return {
id,
mode,
strategy_id: mode === "custom" ? "custom-7" : mode === "stage" ? "stage-ice" : `curated-0${id - 1}`,
strategy_name: name,
selection_date: "2026-07-30",
status,
coverage: 1,
missing_fields: [],
items: status === "completed" ? [candidate] : [],
error_message: "",
};
}
const catalog = {
factor_groups: { "行情与动量": ["return_20d", "amount_billion"], "板块与行业": ["sector_strength"] },
factors: { return_20d: "20日涨幅", amount_billion: "成交额", sector_strength: "板块强度" },
stage: [stageStrategy],
curated,
};
const workspace = {
trade_date: "2026-07-30",
message: "",
catalog,
stage_runs: [run(1, "stage", "冰点抗跌先手")],
curated_runs: [run(2, "curated", "连续分红质量"), run(3, "curated", "动态多因子(基础版)", "no_signal")],
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
custom_runs: [run(4, "custom", "我的选股策略")],
};
const track = {
id: 9,
code: "000001",
name: "平安银行",
sector: "银行",
selection_date: "2026-07-30",
strategy_name: "连续分红质量",
entry_price: 12.35,
t1_open_return: 1.2,
t1_return: 2.4,
t3_return: 4.1,
t5_return: 6.8,
max_gain: 8.2,
max_drawdown: -1.6,
observed_days: 5,
};
async function mockScreener(page) {
await page.route("**/api/screener/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog) }));
await page.route("**/api/screener?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(workspace) }));
await page.route("**/api/screener/tracks", (route) => {
const body = route.request().method() === "GET" ? [track] : { id: 9 };
return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
});
}
test("stage 9 screening preserves deterministic modes, explicit tracking and responsive layout", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text());
});
await mockScreener(page);
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await page.goto("/workspace/screener");
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.getByText("中期动量与行业强度居前")).toBeVisible();
await page.getByRole("button", { name: "加入跟踪" }).click();
await expect(page.getByRole("status")).toContainText("已加入策略跟踪");
await page.screenshot({ path: path.join(evidence, "stage-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "策略选股" }).click();
await expect(page.getByText("策略库")).toBeVisible();
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
await page.getByTitle("图标排列").click();
await expect(page.locator(".strategy-items")).toHaveClass(/is-grid/);
await page.getByRole("button", { name: "自定义选股" }).click();
await expect(page.getByText("合计 100%")).toBeVisible();
await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible();
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "custom-dark-390x844.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "持续 策略跟踪" }).click();
await expect(page.getByRole("heading", { name: "策略持续跟踪" })).toBeVisible();
await expect(page.locator(".topbar-title")).toHaveText("智能选股");
await expect(page.getByText("6.80", { exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "tracking-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("nonmembers see the same screening structure in a disabled state", async ({ page }) => {
await page.route("**/api/screener/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog) }));
await authenticate(page, "stage4user", "Stage4-user-123!");
await page.goto("/workspace/screener");
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.locator(".stage-overview")).toHaveAttribute("aria-disabled", "true");
await page.getByRole("button", { name: "自定义选股" }).click();
await expect(page.getByText("因子与权重")).toBeVisible();
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
});
+9 -2
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)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7)
assert {
"users",
"memberships",
@@ -128,8 +128,15 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"market_insight_snapshots",
"seat_aliases",
"watchlist_entries",
"screener_factor_snapshots",
"screener_factor_values",
"screener_runs",
"custom_screener_strategies",
"strategy_tracks",
"strategy_track_bars",
"strategy_track_events",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (6, 5, 4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (7, 6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import json
import pytest
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.screener.catalog import (
CatalogError,
factor_catalog,
strategy_catalog,
validate_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
def _formula(field: str = "close", *, minimum: float = 0) -> dict:
return {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": field, "op": ">", "value": minimum}],
"score": [{"field": field, "weight": 1.0, "direction": "desc"}],
"limit": 10,
"min_score": 0,
}
def _row(identifier: str, close: float | None) -> dict:
return {
"identifier": identifier,
"code": identifier.split(".")[0],
"name": identifier,
"sector": "测试行业",
"listed_days": 500,
"is_st": False,
"close": close,
"pct_chg": 1,
"amount_billion": 2,
}
def _users(database: Database) -> None:
with database.transaction() as connection:
connection.executemany(
"""
INSERT INTO users (
id, username, username_key, password_hash, is_admin,
status, created_at, updated_at
) VALUES (?, ?, ?, 'hash', 0, 'active', '2026-07-30', '2026-07-30')
""",
((1, "account-a", "account-a"), (2, "account-b", "account-b")),
)
def _snapshot(database: Database, repository: ScreenerRepository) -> int:
with database.transaction() as connection:
return repository.save_factor_snapshot(
connection,
trade_date="2026-07-30",
version="fixture-v1",
observed_at="2026-07-30T15:10:00+08:00",
state="final",
sources=["fixture"],
coverage={"market": 1},
rows=[_row("000001.SZ", 10)],
)
def test_catalog_has_the_exact_reviewed_scope() -> None:
factors = factor_catalog()
strategies = strategy_catalog()
assert len(factors["factors"]) == 109
assert len(strategies) == 36
assert sum(item["kind"] == "stage" for item in strategies) == 7
assert sum(item["kind"] == "curated" for item in strategies) == 29
assert {item["id"] for item in strategies if item["kind"] == "curated"} == {
f"curated-{index:02d}" for index in range(1, 30)
}
def test_curated_strategies_run_independently_of_emotion_phase() -> None:
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
_stage, curated = automatic_strategies(regime)
assert len(curated) == 29
assert {item["id"] for item in curated} == {
f"curated-{index:02d}" for index in range(1, 30)
}
def test_formula_is_deterministic_and_best_value_scores_first() -> None:
rows = [_row("000002.SZ", 20), _row("000001.SZ", 20), _row("000003.SZ", 10)]
first = execute_formula(rows, _formula(), {"market": 1})
second = execute_formula(list(reversed(rows)), _formula(), {"market": 1})
assert first == second
assert [item["identifier"] for item in first["items"]] == [
"000001.SZ",
"000002.SZ",
"000003.SZ",
]
assert first["items"][0]["score"] == 1
assert first["items"][-1]["score"] == 0
def test_missing_required_factor_and_complete_no_match_are_distinct() -> None:
incomplete = execute_formula(
[{**_row("000001.SZ", 10), "roic": None}],
_formula("roic"),
{"financial": 1},
)
no_signal = execute_formula(
[_row("000001.SZ", 10)],
_formula(minimum=100),
{"market": 1},
)
assert incomplete["status"] == "data_incomplete"
assert incomplete["missing_fields"] == ["roic"]
assert no_signal["status"] == "no_signal"
assert no_signal["missing_fields"] == []
def test_formula_weights_must_total_one_hundred_percent() -> None:
formula = _formula()
formula["score"][0]["weight"] = 0.9
with pytest.raises(CatalogError, match="100%"):
validate_formula(formula)
def test_formula_rejects_invalid_comparisons_and_duplicate_scores() -> None:
malformed = _formula()
malformed["filters"][0] = {"field": "close", "op": "between", "value": [20]}
with pytest.raises(CatalogError, match="两个边界"):
validate_formula(malformed)
duplicate = _formula()
duplicate["score"].append({"field": "close", "weight": 0.5, "direction": "desc"})
duplicate["score"][0]["weight"] = 0.5
with pytest.raises(CatalogError, match="不能重复"):
validate_formula(duplicate)
def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
database = Database(tmp_path / "screener.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
first = repository.save_custom_strategy(connection, 1, "我的策略", _formula())
repository.save_custom_strategy(connection, 2, "我的策略", _formula())
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
repository.add_track(
connection,
user_id=1,
run=run,
candidate={**_row("000001.SZ", 10), "score": 1},
)
with database.read() as connection:
assert [row["id"] for row in repository.custom_strategies(connection, 1)] == [first["id"]]
assert len(repository.custom_strategies(connection, 2)) == 1
assert len(repository.tracks(connection, 1)) == 1
assert repository.tracks(connection, 2) == ()
def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
database = Database(tmp_path / "runs.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
with database.read() as connection:
assert repository.tracks(connection, 1) == ()
stored = connection.execute(
"SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],)
).fetchone()
assert len(json.loads(stored["result_json"])) == 1
def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None:
database = Database(tmp_path / "tracking.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
track_id = repository.add_track(
connection,
user_id=1,
run=run,
candidate={**_row("000001.SZ", 10), "score": 1},
)
bars = (
("2026-07-31", 10.5, 11.2, 9.5, 11.0),
("2026-08-03", 11.0, 11.5, 10.0, 10.5),
("2026-08-04", 10.5, 12.5, 10.2, 12.0),
("2026-08-05", 12.0, 12.2, 9.0, 11.5),
("2026-08-06", 11.5, 14.0, 11.0, 13.0),
)
for trade_date, open_price, high, low, close in bars:
repository.save_track_bar(
connection,
track_id,
trade_date,
{"open": open_price, "high": high, "low": low, "close": close},
)
assert repository.record_track_event(connection, track_id, "t1") is True
assert repository.record_track_event(connection, track_id, "t1") is False
assert repository.record_track_event(connection, track_id, "t5") is True
assert repository.record_track_event(connection, track_id, "t5") is False
with database.read() as connection:
track = repository.tracks(connection, 1)[0]
decoded = decode_track(track, repository.track_bars(connection, track_id))
events = connection.execute(
"SELECT milestone FROM strategy_track_events WHERE track_id = ? ORDER BY milestone",
(track_id,),
).fetchall()
assert decoded["t1_open_return"] == 5
assert decoded["t1_return"] == 10
assert decoded["t3_return"] == 20
assert decoded["t5_return"] == 30
assert decoded["max_gain"] == 40
assert decoded["max_drawdown"] == -10
assert decoded["observed_days"] == 5
assert [row["milestone"] for row in events] == ["t1", "t5"]
def test_repeated_after_close_run_uses_one_persistent_run(tmp_path) -> None:
database = Database(tmp_path / "idempotent.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
first = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(first["id"]),
status="no_signal",
coverage=1,
missing_fields=[],
result=[],
)
repeated = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
count = connection.execute("SELECT COUNT(*) AS total FROM screener_runs").fetchone()
assert repeated["id"] == first["id"]
assert count["total"] == 1
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import httpx
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from tests.support import run_scenario
from tests.test_accounts import (
ADMIN_PASSWORD,
USER_PASSWORD,
csrf_headers,
register,
use_session,
)
def _formula() -> dict:
return {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": "close", "op": ">", "value": 1}],
"score": [{"field": "close", "weight": 1, "direction": "desc"}],
"limit": 10,
"min_score": 0.5,
}
def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin = await register(client, "admin-screener", ADMIN_PASSWORD)
client.cookies.clear()
_, regular = await register(client, "regular-screener", USER_PASSWORD)
catalog = await client.get("/api/screener/catalog")
assert catalog.status_code == 200
assert len(catalog.json()["factors"]) == 109
locked = await client.get("/api/screener?date=2026-07-30")
assert locked.status_code == 403
assert locked.json()["error"]["code"] == "membership_required"
rejected = await client.put(
"/api/screener/custom",
headers=csrf_headers(regular),
json={"name": "越权策略", "formula": _formula()},
)
assert rejected.status_code == 403
use_session(client, admin)
workspace = await client.get("/api/screener?date=2026-07-30")
assert workspace.status_code == 200
assert workspace.json()["trade_date"] is None
saved = await client.put(
"/api/screener/custom",
headers=csrf_headers(admin),
json={"name": "我的策略", "formula": _formula()},
)
assert saved.status_code == 200
assert saved.json()["name"] == "我的策略"
refreshed = await client.get("/api/screener?date=2026-07-30")
assert [item["name"] for item in refreshed.json()["custom_strategies"]] == ["我的策略"]
run_scenario(application, scenario)