rebuild(stage-7): deliver ladder and sector rotation
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-7");
|
||||
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
|
||||
|
||||
async function authenticate(page) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill("stage7admin");
|
||||
await page.getByLabel("密码").fill("Stage7-pass-123!");
|
||||
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 overview = { up_count: 2800, down_count: 2100, flat_count: 100, limit_up: 58, limit_down: 5, broken: 20, seal_rate: 74.4, amount: 1500000000000 };
|
||||
const summary = { context: { requested_date: "2026-07-30", actual_date: "2026-07-29", previous_date: "2026-07-28", observed_at: "2026-07-29T15:00:00+08:00", state: "final", carried_forward: true, message: "沿用最近真实收盘快照" }, values: { ...overview, temperature: 48 } };
|
||||
|
||||
function stock(level, index) {
|
||||
return { identifier: `${level}${String(index).padStart(5, "0")}.SZ`, code: `${level}${String(index).padStart(5, "0")}`, name: `${level}板样本${index}`, streak: level, sector: index % 2 ? "机器人" : "元件", first_time: `09:${String(30 + index).padStart(2, "0")}`, open_times: index % 4, amount: 200000000 + index * 10000000 };
|
||||
}
|
||||
|
||||
const ladders = [7, 5, 4, 3, 2, 1].map((level) => {
|
||||
const count = level === 1 ? 12 : level === 2 ? 10 : 2;
|
||||
return { level, label: level === 1 ? "首板" : `${level}板`, count, stocks: Array.from({ length: count }, (_, index) => stock(level, index + 1)) };
|
||||
});
|
||||
const performance = [1, 2, 3, 4, 5, 7].map((level) => ({ level, advance_rate: 10 + level * 5 }));
|
||||
const names = ["机器人", "元件", "半导体", "油气开采", "保险", "银行", "软件", "通信", "汽车零部件", "电力", "化工", "医药"];
|
||||
const rotation = Array.from({ length: 9 }, (_, day) => ({ trade_date: `2026-07-${String(21 + day).padStart(2, "0")}`, sectors: names.map((name, index) => ({ name, rank: index + 1, count: 8 - index % 5, strength: 96 - index * 3, leader: `${name}龙头`, representative: `${String(index + 1).padStart(6, "0")}.SZ`, trend: day % 2 ? "升温" : "持平" })) }));
|
||||
|
||||
async function mockMarket(page) {
|
||||
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary) }));
|
||||
await page.route("**/api/market/workspaces/*", (route) => {
|
||||
const key = new URL(route.request().url()).pathname.split("/").pop();
|
||||
const base = { trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", carried_forward: false, message: "", overview };
|
||||
if (key === "ladder") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: ladders, history: performance }) });
|
||||
if (key === "rotation") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, history: rotation }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, sentiment: {}, history: [] }) });
|
||||
});
|
||||
await page.route("**/api/market/rotation-members*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", sector_name: "机器人", sector_code: "801072.SI", member_count: 3, quoted_count: 2, coverage: 0.667, items: [{ identifier: "000001.SZ", code: "000001", name: "成分甲", change: 4.2, open: 10, close: 10.4, amount: 500000000, quoted: true }, { identifier: "000002.SZ", code: "000002", name: "成分乙", change: -1.1, open: 8, close: 7.9, amount: 200000000, quoted: true }, { identifier: "000003.SZ", code: "000003", name: "停牌样本", change: null, open: null, close: null, amount: null, quoted: false }] }) }));
|
||||
}
|
||||
|
||||
test("ladder and rotation preserve hierarchy, expansion and dated members", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
|
||||
await mockMarket(page);
|
||||
await authenticate(page);
|
||||
|
||||
await page.getByRole("link", { name: /市场天梯/ }).click();
|
||||
await expect(page.locator(".ladder-level").nth(0)).toContainText("7板");
|
||||
await expect(page.locator(".ladder-level").nth(1)).toContainText("6板");
|
||||
await page.getByRole("button", { name: "展开更多(4)" }).click();
|
||||
await expect(page.getByRole("button", { name: "收起" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "收起" }).click();
|
||||
await page.getByRole("button", { name: "按开板次数" }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight)).toBe(true);
|
||||
await page.screenshot({ path: path.join(evidence, "ladder-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.getByRole("link", { name: /板块轮动/ }).click();
|
||||
await expect(page.locator(".rotation-day")).toHaveCount(9);
|
||||
await page.locator(".rotation-day").first().getByRole("button", { name: /机器人/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "机器人成分股" })).toBeVisible();
|
||||
await expect(page.getByText("成分甲")).toBeVisible();
|
||||
await page.getByRole("button", { name: "由近到远" }).click();
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
expect(await page.locator(".rotation-sector.heat-high").first().evaluate((element) => getComputedStyle(element).backgroundColor)).toBe("rgb(64, 38, 42)");
|
||||
await page.screenshot({ path: path.join(evidence, "rotation-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
await page.screenshot({ path: path.join(evidence, "rotation-dark-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
@@ -314,6 +314,10 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
)
|
||||
assert snapshot["overview"]["amount"] == 500_000
|
||||
assert snapshot["broken"][0]["distance_to_limit"] == 20
|
||||
assert snapshot["ladders"][0]["level"] == 2
|
||||
assert snapshot["ladders"][0]["stocks"][0]["code"] == "000001"
|
||||
assert snapshot["sector_rotation"][0]["name"] == "测试行业"
|
||||
assert snapshot["sector_rotation"][0]["delta"] == -4
|
||||
assert [row["outcome"] for row in snapshot["yesterday_limits"]] == [
|
||||
"晋级",
|
||||
"炸板",
|
||||
@@ -336,6 +340,43 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
}
|
||||
|
||||
|
||||
def test_sector_members_are_normalized_and_persistently_cached(tmp_path) -> None:
|
||||
database = Database(tmp_path / "members.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = MarketRepository()
|
||||
provider = TushareProvider("test-token")
|
||||
calls = 0
|
||||
|
||||
def members(*_):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return calculation_result(
|
||||
[
|
||||
{
|
||||
"sector_code": "801082.SI",
|
||||
"sector_name": "元件",
|
||||
"ts_code": "002141.SZ",
|
||||
"name": "贤丰控股",
|
||||
"change": 3.2,
|
||||
"open": 4.1,
|
||||
"close": 4.3,
|
||||
"amount": 123_000_000,
|
||||
"quoted": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
provider.sector_members = members
|
||||
market = DataGateway(database, repository, (provider,), DataSourcePolicy())
|
||||
first = market.sector_members("2026-07-29", "元件", "002141.SZ")
|
||||
second = market.sector_members("2026-07-29", "元件", "002141.SZ")
|
||||
|
||||
assert calls == 1
|
||||
assert first == second
|
||||
assert first["items"][0]["code"] == "002141"
|
||||
assert first["quoted_count"] == 1
|
||||
|
||||
|
||||
def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
|
||||
snapshot = {
|
||||
"overview": {
|
||||
@@ -401,7 +442,11 @@ def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) ->
|
||||
"previous_limit_up": calculation_result([]),
|
||||
"price_limits": calculation_result([]),
|
||||
}
|
||||
service = MarketSnapshotService(database, repository, provider)
|
||||
service = MarketSnapshotService(
|
||||
database,
|
||||
repository,
|
||||
DataGateway(database, repository, (provider,), DataSourcePolicy()),
|
||||
)
|
||||
with pytest.raises(SnapshotSyncError, match="覆盖率"):
|
||||
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
|
||||
with database.read() as connection:
|
||||
|
||||
@@ -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)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -124,8 +124,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"market_entities",
|
||||
"market_summaries",
|
||||
"chart_series",
|
||||
"sector_member_snapshots",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (4, 3, 2, 1)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
Reference in New Issue
Block a user