rebuild(stage-8): deliver market insight workspaces

This commit is contained in:
leefer
2026-07-30 04:12:04 +08:00
parent a18e8e9d27
commit 976a5cac03
39 changed files with 3671 additions and 14 deletions
+144
View File
@@ -0,0 +1,144 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-8");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage8admin");
await page.getByLabel("密码").fill("Stage8-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 summary = {
context: {
requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29",
observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "",
},
values: { up_count: 2800, down_count: 2100, limit_up: 58, limit_down: 5, broken: 20, seal_rate: 74.4, amount: 1500000000000, temperature: 48 },
};
const candidate = (code, name, expectation, score) => ({
identifier: `${code}.SZ`, code, name, sector: "机器人", source_label: "昨日涨停 · 同花顺热榜",
expectation, change: expectation === "超预期" ? 6.8 : 2.1, expected_change: 4,
attention_score: score, volume_ratio: 1.8, amount_million: 22.5, is_market_core: score > 80,
});
const auction = {
requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T09:25:00+08:00",
state: "final", carried_forward: false, message: "", phase: "finalized", current_available: true, coverage: 0.98,
summary: { stock_count: 5200, focus_count: 2, one_price_count: 1, amount_billion: 42.03, amount_change_previous: -12.4 },
expectations: { 超预期: 1, 符合预期: 1, 低于预期: 0 },
focus_rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)],
rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)],
one_price_rows: [{ ...candidate("000003", "国华网安", "", 0), attention_score: null, expectation: "" }],
watchlist_rows: [], watchlist_ready: false,
themes: { carry: [{ name: "机器人", status: "强承接", matched_count: 6, median_change: 4.2 }], new_themes: [{ name: "算力租赁", stock_count: 3, leaders: ["平安银行", "万科A"] }] },
amount_history: [
{ trade_date: "2026-07-28", amount_billion: 36.2, stock_count: 5180 },
{ trade_date: "2026-07-29", amount_billion: 47.8, stock_count: 5190 },
{ trade_date: "2026-07-30", amount_billion: 42.03, stock_count: 5200 },
],
};
const themes = {
requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00",
state: "archive", carried_forward: false, message: "", summary: { theme_count: 3, quoted_count: 3, up_count: 2, down_count: 1 },
items: [
{ code: "885001.TI", name: "机器人", member_count: 120, change: 3.2, turnover_rate: 4.8, hot_rank: 1, has_quote: true },
{ code: "885002.TI", name: "算力租赁", member_count: 82, change: 1.5, turnover_rate: 3.2, hot_rank: 2, has_quote: true },
{ code: "885003.TI", name: "保险", member_count: 18, change: -0.8, turnover_rate: 1.1, hot_rank: 8, has_quote: true },
],
};
const themeDetail = {
trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", state: "archive", message: "",
theme: themes.items[0], summary: { member_count: 3, quoted_count: 2, up_count: 2, down_count: 0, turnover_rate: 4.8 },
members: [
{ identifier: "000001.SZ", code: "000001", name: "平安银行", change: 3.2, close: 12.3, amount: 500000000, quoted: true },
{ identifier: "000002.SZ", code: "000002", name: "万科A", change: 1.1, close: 8.4, amount: 220000000, quoted: true },
{ identifier: "000003.SZ", code: "000003", name: "停牌样本", change: null, close: null, amount: null, quoted: false },
],
};
const hotRow = (rank, code, name, dual = true) => ({ rank, identifier: `${code}.SZ`, code, name, change: 2.4, price: 12.2, ths_rank: rank, dc_rank: dual ? rank + 1 : null, dual_source: dual, rank_change: 2, concepts: ["机器人"], reason: "市场关注度上升" });
const popularity = {
requested_date: "2026-07-30", trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00",
state: "archive", carried_forward: true, message: "当日榜单尚未生成,显示最近有效榜单",
summary: { ths_count: 3, dc_count: 3, dual_count: 2 },
combined: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A"), hotRow(3, "000003", "国华网安", false)],
ths: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A")],
dc: [hotRow(1, "000002", "万科A"), hotRow(2, "000001", "平安银行")],
};
const dragon = {
requested_date: "2026-07-30", trade_date: "2026-07-30", previous_date: "2026-07-29",
observed_at: "2026-07-30T18:00:00+08:00", state: "archive", carried_forward: false,
status: "partial", message: "部分营业部尚未归类",
summary: { official_stock_count: 76, trader_count: 2, operation_count: 3, unclassified_count: 1, net_million: 38.5 },
traders: [
{ name: "章盟主", description: "偏好主线核心与大成交标的", net_million: 28.5, operation_count: 2 },
{ name: "作手新一", description: "重视情绪拐点与辨识度", net_million: 12.2, operation_count: 1 },
],
operations: [
{ identifier: "000001.SZ", code: "000001", name: "平安银行", direction: "买入", buy_million: 30, sell_million: 5, net_million: 25, trader_name: "章盟主", seat_name: "国泰君安上海江苏路", reason: "日涨幅偏离值达7%", recognized: true },
{ identifier: "000002.SZ", code: "000002", name: "万科A", direction: "买入", buy_million: 15, sell_million: 2.8, net_million: 12.2, trader_name: "作手新一", seat_name: "国泰君安南京太平南路", reason: "日振幅值达15%", recognized: true },
{ identifier: "000003.SZ", code: "000003", name: "国华网安", direction: "买入", buy_million: 4.1, sell_million: 2.8, net_million: 1.3, trader_name: "", seat_name: "测试待归类营业部", reason: "连续三个交易日涨幅偏离", recognized: false },
],
unclassified_seats: [{ seat_name: "测试待归类营业部", net_million: 1.3, operation_count: 1 }],
profiles: [
{ name: "章盟主", description: "偏好主线核心与大成交标的", organization_count: 2, organizations: ["国泰君安上海江苏路", "国泰君安上海新闸路"] },
{ name: "作手新一", description: "重视情绪拐点与辨识度", organization_count: 1, organizations: ["国泰君安南京太平南路"] },
],
};
async function mockStage8(page) {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", carried_forward: false, message: "", overview: summary.values, sentiment: {}, history: [] }) }));
await page.route("**/api/market/insights/*", (route) => {
const key = new URL(route.request().url()).pathname.split("/").pop();
const payload = key === "auction" ? auction : key === "themes" ? themes : key === "popularity" ? popularity : dragon;
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
});
await page.route("**/api/market/themes/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(themeDetail) }));
await page.route("**/api/market/entities/theme/*/charts/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ entity_type: "theme", identifier: "885001.TI", code: "885001", name: "机器人", interval: "day", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", previous_close: 1000, range_start: null, range_end: null, points: [{ time: "2026-07-29", open: 1000, high: 1020, low: 990, close: 1010, volume: 100, amount: 1000, average: null }, { time: "2026-07-30", open: 1010, high: 1060, low: 1005, close: 1050, volume: 150, amount: 1600, average: null }] }) }));
}
test("stage 8 market insights preserve lifecycle, hierarchy and empty-state semantics", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockStage8(page);
await authenticate(page);
await page.getByRole("link", { name: /集合竞价/ }).click();
await expect(page.getByText("竞价成交额", { exact: true })).toBeVisible();
await expect(page.getByText("42.03 亿", { exact: true })).toBeVisible();
await expect(page.locator(".auction-bars").getByText("42.03", { exact: true })).toBeVisible();
await page.screenshot({ path: path.join(evidence, "auction-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /题材库/ }).click();
await expect(page.getByRole("heading", { name: "机器人成分股" })).toHaveCount(0);
await expect(page.getByText("停牌样本")).toBeVisible();
await page.getByRole("button", { name: /机器人/ }).first().hover();
await expect(page.locator(".theme-preview-popover")).toBeVisible();
await page.getByRole("link", { name: /人气热榜/ }).click();
await page.getByRole("button", { name: "夜间" }).click();
await expect(page.locator(".popularity-summary-strip article")).toHaveCount(3);
expect(await page.locator(".popularity-summary-strip article").first().evaluate((element) => getComputedStyle(element).boxShadow)).toBe("none");
await page.screenshot({ path: path.join(evidence, "popularity-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /龙虎榜/ }).click();
await expect(page.getByRole("heading", { name: "当日操作明细" })).toBeVisible();
await expect(page.getByText("测试待归类营业部").first()).toBeVisible();
await page.getByRole("button", { name: "游资档案" }).click();
await expect(page.getByRole("heading", { name: "章盟主" })).toBeVisible();
await expect(page.locator(".dialog-backdrop")).toHaveCount(0);
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, "dragon-profiles-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
+401
View File
@@ -0,0 +1,401 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.gateway import MarketDataUnavailable
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.insights.auction import build_auction, build_watchlist_rows
from backend.features.market.insights.dragon import build_dragon_list
from backend.features.market.insights.popularity import build_popularity
from backend.features.market.insights.service import MarketInsightService
from backend.features.market.insights.themes import build_theme_detail, build_theme_library
SHANGHAI = ZoneInfo("Asia/Shanghai")
def test_auction_keeps_market_cores_and_isolates_real_limit_price() -> None:
directory = {
f"000{index:03d}.SZ": {
"identifier": f"000{index:03d}.SZ",
"code": f"000{index:03d}",
"name": "ST样本" if index == 1 else f"样本{index}",
"sector": "机器人",
}
for index in range(1, 36)
}
prior_limits = [
{
"identifier": identifier,
"code": stock["code"],
"name": stock["name"],
"sector": "机器人",
"streak": 3,
"amount": index * 10_000_000,
}
for index, (identifier, stock) in enumerate(directory.items(), start=1)
]
rows = tuple(
{
"ts_code": identifier,
"price": 11 if index == 1 else 10.5,
"pre_close": 10,
"amount": 5_000_000,
"vol": 500_000,
"turnover_rate": 0.2,
"volume_ratio": 1.5,
}
for index, identifier in enumerate(directory, start=1)
)
result = build_auction(
trade_date="2026-07-30",
raw_rows=rows,
price_limits=({"ts_code": "000001.SZ", "up_limit": 11},),
directory=directory,
prior_snapshot={
"limits": prior_limits,
"broken": [],
"sectors": [{"name": "机器人", "count": 35}],
},
ths_hot=(),
dc_hot=(),
history=[
{"trade_date": "2026-07-29", "amount_billion": 1.2, "stock_count": 35}
],
dynamic=False,
)
assert result["summary"]["one_price_count"] == 1
assert result["one_price_rows"][0]["code"] == "000001"
assert result["one_price_rows"][0]["is_market_core"] is True
assert len(result["focus_rows"]) == 34
assert result["summary"]["amount_billion"] == result["amount_history"][-1]["amount_billion"]
def test_watchlist_rows_are_built_only_from_current_account_entries() -> None:
market = [
{
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
"change": 1.5,
"amount_million": 10,
"volume_ratio": 1.2,
}
]
rows = build_watchlist_rows(
market,
[],
[],
({"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},),
)
assert [row["identifier"] for row in rows] == ["000001.SZ"]
assert rows[0]["is_watchlist"] is True
assert rows[0]["available"] is True
def test_watchlist_repository_isolates_accounts(tmp_path) -> None:
database = Database(tmp_path / "watchlists.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
for user_id, username in ((1, "account-a"), (2, "account-b")):
connection.execute(
"""
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')
""",
(user_id, username, username),
)
connection.executemany(
"""
INSERT INTO watchlist_entries (
user_id, identifier, name, sector, created_at
) VALUES (?, ?, ?, ?, '2026-07-30')
""",
(
(1, "000001.SZ", "平安银行", "银行"),
(2, "000002.SZ", "万科A", "房地产"),
),
)
with database.read() as connection:
first = repository.watchlist(connection, 1)
second = repository.watchlist(connection, 2)
assert [row["identifier"] for row in first] == ["000001.SZ"]
assert [row["identifier"] for row in second] == ["000002.SZ"]
def test_popularity_preserves_single_source_without_false_consensus() -> None:
ths = (
{
"data_type": "热股",
"rank": 1,
"ts_code": "000001.SZ",
"ts_name": "平安银行",
"pct_change": 2,
"concept": '["银行"]',
},
)
result = build_popularity("2026-07-30", ths, (), (), ())
assert result["summary"] == {"ths_count": 1, "dc_count": 0, "dual_count": 0}
assert result["combined"][0]["dual_source"] is False
assert result["combined"][0]["concepts"] == ["银行"]
def test_theme_library_and_detail_keep_quote_and_member_empty_states_separate() -> None:
library = build_theme_library(
"2026-07-30",
(
{
"ts_code": "885001.TI",
"name": "机器人",
"count": 2,
"exchange": "A",
"type": "N",
},
),
(),
(),
)
assert library["summary"]["theme_count"] == 1
assert library["summary"]["quoted_count"] == 0
detail = build_theme_detail(
"2026-07-30",
library["items"][0],
({"con_code": "000001.SZ", "con_name": "平安银行"},),
(),
)
assert detail["summary"]["member_count"] == 1
assert detail["summary"]["quoted_count"] == 0
assert detail["members"][0]["quoted"] is False
def test_dragon_list_distinguishes_missing_and_unclassified_seats() -> None:
stocks = (
{
"ts_code": "000001.SZ",
"name": "平安银行",
"pct_change": 3.2,
"reason": "日涨幅偏离值达7%",
},
)
missing = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=(),
aliases={},
)
assert missing["status"] == "detail_missing"
assert "1 只股票上榜" in missing["message"]
seats = (
{
"ts_code": "000001.SZ",
"exalter": "测试营业部",
"buy": 20_000_000,
"sell": 5_000_000,
"net_buy": 15_000_000,
},
)
pending = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=seats,
aliases={},
)
assert pending["status"] == "unclassified"
assert pending["summary"]["unclassified_count"] == 1
classified = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=seats,
aliases={"测试营业部": "测试游资"},
)
assert classified["status"] == "success"
assert classified["traders"][0]["name"] == "测试游资"
class AuctionGateway:
def __init__(self, dynamic_available: bool = True) -> None:
self.dynamic_available = dynamic_available
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
dates = ("2026-07-30", "2026-07-29", "2026-07-28")
return tuple(value for value in dates if value <= through)[:limit]
def insight_inputs(
self, kind: str, trade_date: str, previous: str = "", identifier: str = ""
) -> dict:
assert kind == "auction"
return {
"auction": result(()),
"price_limits": result(({"ts_code": "000001.SZ", "up_limit": 11},)),
"ths_hot": result(()),
"dc_hot": result(()),
}
def stock_directory(self) -> dict[str, dict]:
return {
"000001.SZ": {
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
}
}
def dynamic_auction(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
if not self.dynamic_available:
raise MarketDataUnavailable("动态竞价暂不可用")
assert identifiers == ("000001.SZ",)
assert start_time.endswith("09:15:00")
return result(
(
{
"thscode": "000001.SZ",
"time": end_time,
"latest": 10.5,
"preClose": 10,
"volume": 1_000_000,
"amount": 10_500_000,
"turnoverRatio": 0.2,
"volumeRatio": 1.5,
},
),
source=DataSource.IFIND,
state=SnapshotState.REALTIME,
)
def result(
rows: tuple[dict, ...],
*,
source: DataSource = DataSource.TUSHARE,
state: SnapshotState = SnapshotState.ARCHIVE,
) -> ProviderResult:
return ProviderResult(
rows,
ObservationMetadata(
source=source,
observed_at=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI),
unit="mixed",
adjustment="not_applicable",
freshness_seconds=0,
coverage=1,
state=state,
usage=DataUsage.CALCULATION,
),
)
def insight_service(tmp_path, dynamic_available: bool = True) -> MarketInsightService:
database = Database(tmp_path / "insights.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
prior = {
"trade_date": "2026-07-29",
"limits": [
{
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
"streak": 1,
"amount": 200_000_000,
}
],
"broken": [],
"sectors": [{"name": "银行", "count": 1}],
}
with database.transaction() as connection:
repository.save_summary(
connection,
trade_date="2026-07-29",
observed_at="2026-07-29T15:00:00+08:00",
state="final",
source="tushare",
coverage=1,
payload=prior,
)
return MarketInsightService(
database, repository, AuctionGateway(dynamic_available) # type: ignore[arg-type]
)
def test_auction_lifecycle_uses_live_snapshot_then_archives_925_result(tmp_path) -> None:
service = insight_service(tmp_path)
observing = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
)
assert observing["phase"] == "observing"
assert observing["trade_date"] == "2026-07-30"
assert observing["dynamic"] is True
assert observing["current_available"] is True
selection = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 26, tzinfo=SHANGHAI)
)
assert selection["phase"] == "selection"
assert selection["state"] == "final"
with service._database.read() as connection:
stored = service._repository.insight_snapshot(
connection, "auction", "2026-07-30"
)
assert stored is not None
def test_observing_without_dynamic_data_never_disguises_previous_archive(tmp_path) -> None:
service = insight_service(tmp_path, dynamic_available=False)
previous = {
"trade_date": "2026-07-29",
"observed_at": "2026-07-29T09:25:00+08:00",
"state": "archive",
"summary": {"stock_count": 1, "amount_billion": 0.1},
"focus_rows": [],
"one_price_rows": [],
"rows": [],
"amount_history": [],
}
with service._database.transaction() as connection:
service._repository.save_insight_snapshot(
connection,
kind="auction",
trade_date="2026-07-29",
entity_key="",
observed_at=previous["observed_at"],
state="archive",
source="tushare",
coverage=1,
payload=previous,
)
current = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
)
assert current["trade_date"] == "2026-07-29"
assert current["carried_forward"] is True
assert current["current_available"] is False
assert "动态竞价暂不可用" in current["message"]
+5 -2
View File
@@ -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)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6)
assert {
"users",
"memberships",
@@ -125,8 +125,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"market_summaries",
"chart_series",
"sector_member_snapshots",
"market_insight_snapshots",
"seat_aliases",
"watchlist_entries",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)