rebuild(stage-6): deliver emotion and market pools

This commit is contained in:
leefer
2026-07-30 03:03:53 +08:00
parent 889963862a
commit 59f6011ae8
30 changed files with 1751 additions and 9 deletions
+114
View File
@@ -0,0 +1,114 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-6");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage6admin");
await page.getByLabel("密码").fill("Stage6-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();
}
}
function summary() {
return {
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: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
};
}
const overview = { up_count: 2960, down_count: 1980, flat_count: 112, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 };
function history() {
return Array.from({ length: 60 }, (_, index) => ({
trade_date: `2026-07-${String(index + 1).padStart(2, "0")}`,
temperature: 26 + index % 35, phase: index % 3 === 0 ? "退潮" : "修复",
direction: index % 2 ? "升温" : "降温", positive_rate: 45 + index % 20,
seal_rate: 60 + index % 20, limit_up: 30 + index, broken: 9 + index % 15,
limit_down: 2 + index % 8, max_height: 3 + index % 5, amount: 1300000000000 + index * 1000000000,
}));
}
function poolItems() {
return Array.from({ length: 18 }, (_, index) => ({
identifier: `${String(index + 1).padStart(6, "0")}.SZ`, code: String(index + 1).padStart(6, "0"),
name: `样本股票${index + 1}`, streak: index % 5 + 1, change: 9.8 + index / 100,
price: 10 + index, sector: index % 2 ? "半导体" : "机器人", first_time: "09:35", last_time: "14:20",
open_times: index % 3, turnover_rate: 8 + index, amount: 300000000 + index * 10000000,
seal_amount: 50000000 + index * 1000000, reason: index % 2 ? "国产替代" : "产业链催化",
}));
}
function yesterdayItems() {
const outcomes = ["晋级", "红盘", "断板", "炸板", "跌停"];
return Array.from({ length: 15 }, (_, index) => ({
identifier: `${String(index + 101).padStart(6, "0")}.SZ`, code: String(index + 101).padStart(6, "0"),
name: `昨日样本${index + 1}`, prior_streak: index % 7 + 1, current_change: 10 - index,
outcome: outcomes[index % outcomes.length], current_streak: index % outcomes.length === 0 ? index % 7 + 2 : 0,
sector: "电子", reason: "事件催化",
}));
}
function performanceItems() {
return Array.from({ length: 7 }, (_, index) => ({
level: 7 - index, count: 3 + index, advanced: index % 3, red: 1, broken: 1,
opened: index % 2, limit_down: index === 6 ? 1 : 0, advance_rate: 28.6,
positive_rate: 57.1, average_change: 2.35,
}));
}
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 === "emotion") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, sentiment: { score: 42, phase: "退潮", direction: "降温", day_change: -8, confidence: 92, transition_reason: "赚钱效应与连板结构同步转弱", stats: { positive_rate: 53.2, yesterday_count: 47 }, components: [{ key: "breadth", label: "市场宽度", score: 58, weight: 20 }, { key: "limit_ecology", label: "涨停生态", score: 62, weight: 25 }, { key: "profit_effect", label: "赚钱效应", score: 34, weight: 30 }, { key: "ladder_structure", label: "连板结构", score: 38, weight: 15 }, { key: "liquidity", label: "成交活跃度", score: 52, weight: 10 }] }, history: history() }) });
if (key === "yesterday") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: yesterdayItems() }) });
if (key === "performance") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: performanceItems() }) });
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: poolItems() }) });
});
}
test("emotion and pool workspaces remain usable across desktop and mobile", 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 expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
await expect(page.getByText("情绪指标继续走弱")).toBeVisible();
await page.getByRole("button", { name: "60日" }).click();
await expect(page.locator(".emotion-history-table tbody tr")).toHaveCount(60);
expect(await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight)).toBe(true);
await page.screenshot({ path: path.join(evidence, "emotion-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /涨停池/ }).click();
await page.getByRole("button", { name: "3板+" }).click();
await expect(page.locator(".data-table tbody tr")).toHaveCount(10);
await expect(page.getByText("5板")).toBeVisible();
await page.getByRole("button", { name: "夜间" }).click();
await page.screenshot({ path: path.join(evidence, "pool-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.setViewportSize({ width: 3840, height: 2160 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.screenshot({ path: path.join(evidence, "pool-dark-3840x2160.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /涨停表现/ }).click();
await expect(page.getByText("7板")).toBeVisible();
await expect(page.getByRole("heading", { name: "今日结论" })).toBeVisible();
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, "performance-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
+150
View File
@@ -19,9 +19,13 @@ from backend.data.contracts import (
from backend.data.gateway import DataGateway
from backend.data.policy import DataPolicyError, DataSourcePolicy
from backend.data.providers.ifind import IfindProvider
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from tests.support import run_scenario
SHANGHAI = ZoneInfo("Asia/Shanghai")
@@ -256,3 +260,149 @@ def test_search_groups_are_fixed_and_require_authentication(tmp_path) -> None:
assert groups[-1]["items"][0]["name"] == "上证指数"
run_scenario(application, scenario)
def calculation_result(rows) -> ProviderResult:
return ProviderResult(
tuple(rows),
ObservationMetadata(
source=DataSource.TUSHARE,
observed_at=datetime(2026, 7, 29, 15, tzinfo=SHANGHAI),
unit="mixed",
adjustment="not_applicable",
freshness_seconds=0,
coverage=1,
state=SnapshotState.FINAL,
usage=DataUsage.CALCULATION,
),
)
def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> None:
daily = [
{
"ts_code": f"00000{index}.SZ",
"close": 10 + index,
"pct_chg": change,
"amount": 100,
}
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
]
def event(index, streak=1):
return {
"ts_code": f"00000{index}.SZ",
"name": f"样本{index}",
"industry": "测试行业",
"close": 10 + index,
"pct_chg": daily[index - 1]["pct_chg"],
"amount": 100,
"limit_times": streak,
}
snapshot = build_snapshot(
"2026-07-29",
"2026-07-28",
{
"daily": calculation_result(daily),
"limit_up": calculation_result([event(1, 2)]),
"broken": calculation_result([event(2)]),
"limit_down": calculation_result([event(3)]),
"previous_limit_up": calculation_result([event(index) for index in range(1, 6)]),
"price_limits": calculation_result(
[{"ts_code": "000002.SZ", "up_limit": 15, "down_limit": 9}]
),
},
)
assert snapshot["overview"]["amount"] == 500_000
assert snapshot["broken"][0]["distance_to_limit"] == 20
assert [row["outcome"] for row in snapshot["yesterday_limits"]] == [
"晋级",
"炸板",
"跌停",
"红盘",
"断板",
]
performance = snapshot["limit_performance"][0]
assert performance == {
"level": 1,
"count": 5,
"advanced": 1,
"red": 1,
"broken": 1,
"opened": 1,
"limit_down": 1,
"advance_rate": 20.0,
"positive_rate": 60.0,
"average_change": 0.8,
}
def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
snapshot = {
"overview": {
"up_count": 10,
"down_count": 90,
"limit_up": 10,
"limit_down": 100,
"broken": 20,
"seal_rate": 33.3,
"amount": 100_000_000_000,
},
"limits": [{"streak": 1, "amount": 100_000_000} for _ in range(10)],
"yesterday_limits": [],
}
sentiment = calculate_sentiment(snapshot, [])
assert sentiment["score"] <= 15
assert sentiment["phase"] == "冰点"
assert {item["key"]: item["weight"] for item in sentiment["components"]} == {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) -> None:
database = Database(tmp_path / "sync.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
repository.replace_calendar(
connection,
(
{"cal_date": "20260728", "is_open": 1, "pretrade_date": "20260727"},
{"cal_date": "20260729", "is_open": 1, "pretrade_date": "20260728"},
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
repository.replace_stocks(
connection,
tuple(
{
"ts_code": f"{index:06d}.SZ",
"symbol": f"{index:06d}",
"name": f"样本{index}",
"industry": "测试",
"list_status": "L",
}
for index in range(1, 101)
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
provider = TushareProvider("test-token")
provider.snapshot_inputs = lambda *_: {
"daily": calculation_result([{"ts_code": "000001.SZ"}]),
"limit_up": calculation_result([]),
"limit_down": calculation_result([]),
"broken": calculation_result([]),
"previous_limit_up": calculation_result([]),
"price_limits": calculation_result([]),
}
service = MarketSnapshotService(database, repository, provider)
with pytest.raises(SnapshotSyncError, match="覆盖率"):
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
with database.read() as connection:
assert repository.latest_summary(connection, "2026-07-29") is None