rebuild(stage-6): deliver emotion and market pools
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user