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");
|
||||
});
|
||||
Reference in New Issue
Block a user