rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user