rebuild(audit): complete entity detail and market preview

This commit is contained in:
leefer
2026-07-30 09:25:52 +08:00
parent d1b3b977d2
commit 4fc8691eee
27 changed files with 946 additions and 35 deletions
+54
View File
@@ -130,3 +130,57 @@ test("regular users do not receive administrator controls and see intelligent lo
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
await expect(page.getByRole("button", { name: "查看会员状态" })).toBeVisible();
});
test("empty bootstrap, latest default and background refresh preserve the current workspace", async ({ page }) => {
let synchronized = false;
await page.route("**/api/market/summary", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify(synchronized
? {
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 },
}
: {
context: {
requested_date: "2026-07-30", actual_date: null, previous_date: null,
observed_at: null, state: null, carried_forward: false, message: "等待管理员首次同步",
},
values: null,
}),
}));
await page.route("**/api/market/workspaces/emotion?*", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify(synchronized
? {
trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", carried_forward: true,
message: "沿用最近真实收盘快照", overview: { limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
sentiment: { score: 42, phase: "退潮", direction: "降温", confidence: 92, stats: {}, components: [] }, history: [],
}
: { trade_date: null, carried_forward: false, message: "等待管理员首次同步真实收盘行情", overview: {} }),
}));
await page.route("**/api/market/snapshot-sync?*", (route) => {
synchronized = true;
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", temperature: 42 }) });
});
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await expect(page).toHaveURL(/\/workspace\/emotion$/);
await expect(page.getByText("等待管理员首次同步真实收盘行情")).toBeVisible();
await expect(page.locator(".market-strip-row")).not.toContainText(/涨停\s+\d/);
await page.locator(".page-frame").evaluate((element) => { element.dataset.acceptanceMarker = "preserved"; });
await page.getByRole("button", { name: "后台刷新" }).click();
await expect(page.getByRole("status")).toContainText("刷新页面后读取新数据");
await expect(page).toHaveURL(/\/workspace\/emotion$/);
await expect(page.locator('[data-acceptance-marker="preserved"]')).toBeVisible();
await expect(page.getByText("等待管理员首次同步真实收盘行情")).toBeVisible();
await page.reload();
await expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
await expect(page.locator(".topbar .date-input")).toHaveValue("2026-07-29");
await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
});
+67
View File
@@ -86,6 +86,35 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) });
});
await page.route("**/api/market/entities/stock/000001.SZ/charts/*", (route) => {
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...chartPayload(interval), entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行" }) });
});
await page.route("**/api/market/entities/*/*/detail?*", (route) => {
const stock = route.request().url().includes("/stock/");
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({
entity: stock
? { entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行", sector: "银行" }
: { entity_type: "index", identifier: "000001.SH", code: "000001", name: "上证指数", sector: null },
trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00",
price: stock ? 12.35 : 3604, previous_close: stock ? 12.05 : 3594,
change: stock ? 2.4896 : 0.2782,
metrics: [
{ key: "open", label: "开盘", value: stock ? 12.1 : 3585, unit: "元" },
{ key: "high", label: "最高", value: stock ? 12.5 : 3612, unit: "元" },
{ key: "low", label: "最低", value: stock ? 12.0 : 3570, unit: "元" },
{ key: "amount", label: "成交额", value: 1860000000, unit: "元" },
],
money_flow: stock ? { available: true, net_million: 12.5, large_million: 8.1, net_5d_million: 35.2, flow_to_circ_mv_5d: 0.0185 } : null,
event: stock ? { status: "涨停", reason: "银行板块走强", streak: 1, first_time: "09:35", last_time: "14:20", open_times: 0, seal_amount: 50000000 } : null,
}),
});
});
await page.route("**/api/review?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", watchlist: [], daily: null, history: [], trades: [], trade_summary: { total: 0, realized: 0, win_rate: null, pnl_amount: null, average_position: null } }) }));
await page.route("**/api/review/stock-notes/*", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
await page.route("**/api/review/watchlist", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ identifier: "000001.SZ", name: "平安银行" }) }));
await authenticate(page);
await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
@@ -97,19 +126,57 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
await expect(dialog.getByRole("button", { name: /上证指数/ })).toBeVisible();
await expect(dialog.locator(".market-chart")).toBeVisible();
await expect(dialog).toContainText("数据日期 2026-07-29");
const risingCandle = dialog.locator(".candle-up").first();
const candleGeometry = await risingCandle.evaluate((group) => {
const lines = [...group.querySelectorAll("line")];
const rect = group.querySelector("rect");
return {
upperEnd: Number(lines[0]?.getAttribute("y2")),
bodyTop: Number(rect?.getAttribute("y")),
lowerStart: Number(lines[1]?.getAttribute("y1")),
bodyBottom: Number(rect?.getAttribute("y")) + Number(rect?.getAttribute("height")),
bodyFill: getComputedStyle(rect).fill,
bodyStroke: getComputedStyle(rect).stroke,
};
});
expect(candleGeometry.upperEnd).toBeCloseTo(candleGeometry.bodyTop, 5);
expect(candleGeometry.lowerStart).toBeCloseTo(candleGeometry.bodyBottom, 5);
expect(candleGeometry.bodyFill).not.toBe(candleGeometry.bodyStroke);
await page.screenshot({ path: path.join(evidence, "search-preview-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await dialog.getByRole("button", { name: /上证指数/ }).click();
await expect(page).toHaveURL(/\/market\/index\/000001.SH/);
await expect(page.getByRole("heading", { name: "上证指数" })).toBeVisible();
await expect(page.getByRole("heading", { name: "资金流" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "事件逻辑" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "个股复盘笔记" })).toHaveCount(0);
await page.getByRole("button", { name: "分时" }).click();
await expect(page.locator(".chart-zero")).toHaveCount(1);
await expect(page.locator(".preview-meta")).toContainText("09:3015:00");
await page.getByRole("button", { name: "夜间" }).click();
const darkSurfaces = await page.evaluate(() => ({
chart: getComputedStyle(document.querySelector(".market-chart")).backgroundColor,
panel: getComputedStyle(document.querySelector(".market-preview")).backgroundColor,
canvas: getComputedStyle(document.documentElement).backgroundColor,
}));
expect(darkSurfaces.chart).toBe(darkSurfaces.panel);
expect(darkSurfaces.chart).not.toBe("rgb(255, 255, 255)");
expect(darkSurfaces.canvas).not.toBe("rgb(255, 255, 255)");
await page.screenshot({ path: path.join(evidence, "entity-detail-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.goto("/market/stock/000001.SZ?code=000001&name=%E5%B9%B3%E5%AE%89%E9%93%B6%E8%A1%8C&sector=%E9%93%B6%E8%A1%8C");
await expect(page.getByRole("heading", { name: "平安银行" })).toBeVisible();
await expect(page.locator(".entity-detail-price")).toContainText("+2.49%");
await expect(page.getByRole("heading", { name: "资金流" })).toBeVisible();
await expect(page.getByText("银行板块走强")).toBeVisible();
await expect(page.getByRole("heading", { name: "个股复盘笔记" })).toBeVisible();
await expect(page.getByRole("button", { name: "加入自选" })).toBeVisible();
await expect(page.getByRole("button", { name: "进入观势" })).toBeVisible();
await page.getByRole("button", { name: "加入自选" }).click();
await expect(page.getByRole("button", { name: "移出自选" })).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(".market-chart")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
+20 -1
View File
@@ -89,19 +89,38 @@ test("emotion and pool workspaces remain usable across desktop and mobile", asyn
await expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
await expect(page.getByText("情绪指标继续走弱")).toBeVisible();
await expect(page.locator(".phase-warning")).not.toContainText("筛选门槛");
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);
const finalHistoryRow = page.locator(".emotion-history-table tbody tr").last();
await finalHistoryRow.scrollIntoViewIfNeeded();
expect(await finalHistoryRow.evaluate((row) => row.getBoundingClientRect().bottom <= window.innerHeight - 30)).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 page.getByLabel("搜索股池").fill("样本股票5");
await expect(page.locator(".data-table tbody tr")).toHaveCount(1);
const statusbar = await page.locator(".statusbar").evaluate((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return { position: style.position, bottom: Math.round(window.innerHeight - rect.bottom) };
});
expect(statusbar).toEqual({ position: "fixed", bottom: 0 });
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);
const density = await page.evaluate(() => ({
contentWidth: document.querySelector(".page-frame").getBoundingClientRect().width,
fontSize: getComputedStyle(document.body).fontSize,
topbarHeight: document.querySelector(".topbar").getBoundingClientRect().height,
}));
expect(density.contentWidth).toBeLessThanOrEqual(2200);
expect(density.fontSize).toBe("13px");
expect(density.topbarHeight).toBe(46);
await page.screenshot({ path: path.join(evidence, "pool-dark-3840x2160.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /涨停表现/ }).click();
+36 -8
View File
@@ -45,6 +45,14 @@ const stageStrategy = {
formula,
};
const stageStrategyTwo = {
...stageStrategy,
id: "stage-divergence",
name: "分化去弱留强",
display_name: "分化去弱留强",
regimes: ["divergence"],
};
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: "多因子" } } },
@@ -63,17 +71,21 @@ const candidate = {
risk_flags: [],
};
function run(id, mode, name, status = "completed") {
function candidateNamed(code, name, reason) {
return { ...candidate, identifier: `${code}.SZ`, code, name, reason };
}
function run(id, mode, name, status = "completed", strategyId = "", item = candidate) {
return {
id,
mode,
strategy_id: mode === "custom" ? "custom-7" : mode === "stage" ? "stage-ice" : `curated-0${id - 1}`,
strategy_id: strategyId || (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] : [],
items: status === "completed" ? [item] : [],
error_message: "",
};
}
@@ -81,7 +93,7 @@ function run(id, mode, name, status = "completed") {
const catalog = {
factor_groups: { "行情与动量": ["return_20d", "amount_billion"], "板块与行业": ["sector_strength"] },
factors: { return_20d: "20日涨幅", amount_billion: "成交额", sector_strength: "板块强度" },
stage: [stageStrategy],
stage: [stageStrategy, stageStrategyTwo],
curated,
};
@@ -89,10 +101,16 @@ const workspace = {
trade_date: "2026-07-30",
message: "",
catalog,
stage_runs: [run(1, "stage", "冰点抗跌先手")],
curated_runs: [run(2, "curated", "连续分红质量"), run(3, "curated", "动态多因子(基础版)", "no_signal")],
stage_runs: [
run(1, "stage", "冰点抗跌先手", "completed", "stage-ice", candidateNamed("000011", "冰点样本", "冰点阶段相对抗跌")),
run(5, "stage", "分化去弱留强", "completed", "stage-divergence", candidateNamed("000012", "分化样本", "分化阶段承接较强")),
],
curated_runs: [
run(2, "curated", "连续分红质量", "completed", "curated-01", candidateNamed("000021", "分红样本", "分红与质量条件满足")),
run(3, "curated", "动态多因子(基础版)", "completed", "curated-02", candidateNamed("000022", "多因子样本", "综合因子得分居前")),
],
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
custom_runs: [run(4, "custom", "我的选股策略")],
custom_runs: [run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中"))],
};
const track = {
@@ -131,7 +149,10 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await page.goto("/workspace/screener");
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.getByText("中期动量与行业强度居前")).toBeVisible();
await expect(page.locator(".screener-results")).toContainText("冰点样本");
await page.getByRole("button", { name: "分化去弱留强", exact: true }).click();
await expect(page.locator(".screener-results")).toContainText("分化样本");
await expect(page.locator(".screener-results")).not.toContainText("冰点样本");
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 });
@@ -139,12 +160,18 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await page.getByRole("button", { name: "策略选股" }).click();
await expect(page.getByText("策略库")).toBeVisible();
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
await expect(page.locator(".screener-results")).toContainText("分红样本");
await page.locator(".strategy-items").getByRole("button", { name: /动态多因子/ }).click();
await expect(page.locator(".screener-results")).toContainText("多因子样本");
await expect(page.locator(".screener-results")).not.toContainText("分红样本");
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 expect(page.locator(".screener-results")).toContainText("自定义样本");
await expect(page.locator(".screener-results")).not.toContainText("多因子样本");
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
@@ -166,6 +193,7 @@ test("nonmembers see the same screening structure in a disabled state", async ({
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.locator(".stage-overview")).toHaveAttribute("aria-disabled", "true");
await expect(page.locator(".stage-flow .done")).toHaveCount(0);
await page.getByRole("button", { name: "自定义选股" }).click();
await expect(page.getByText("因子与权重")).toBeVisible();
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
+81
View File
@@ -204,6 +204,87 @@ def test_latest_daily_chart_drops_empty_premarket_bar(tmp_path) -> None:
assert series.points[-1].amount == 13320
def test_entity_detail_uses_selected_bar_previous_close_and_complete_stock_sections(
tmp_path,
) -> None:
market = gateway(tmp_path)
database = market._database
repository = MarketRepository()
with database.transaction() as connection:
connection.execute(
"UPDATE market_summaries SET payload_json = ? WHERE trade_date = ?",
(
json.dumps(
{
"overview": {},
"limits": [
{
"identifier": "000001.SZ", "code": "000001",
"reason": "银行板块走强", "streak": 1,
"first_time": "09:35", "last_time": "14:20", "open_times": 0,
}
],
}
),
"2026-07-29",
),
)
snapshot_id = connection.execute(
"""
INSERT INTO screener_factor_snapshots
(trade_date, version, observed_at, state, source_set_json,
coverage_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
,
(
"2026-07-29",
"detail-v1",
"2026-07-29T15:05:00+08:00",
"final",
'["tushare"]',
"{}",
"2026-07-29T15:05:00+08:00",
),
).lastrowid
connection.execute(
"""
INSERT INTO screener_factor_values
(snapshot_id, identifier, code, name, sector, listed_days, is_st, payload_json)
VALUES (?, '000001.SZ', '000001', '平安银行', '银行', 1000, 0, ?)
""",
(
snapshot_id,
json.dumps(
{
"name": "平安银行", "sector": "银行", "turnover_rate": 2.5,
"return_5d": 3.2, "return_20d": 8.6, "total_mv_billion": 2100,
"circ_mv_billion": 1900, "net_flow_million": 12.5,
"large_flow_million": 8.1, "net_flow_5d_million": 35.2,
"flow_to_circ_mv_5d": 0.0185,
}
),
),
)
detail = MarketSnapshotService(database, repository, market).entity_detail(
"stock", "000001.SZ", "2026-07-29"
)
assert detail["trade_date"] == "2026-07-29"
assert detail["price"] == 11.1
assert detail["previous_close"] == 10.8
assert detail["change"] == pytest.approx(2.7778)
assert detail["entity"]["name"] == "平安银行"
assert detail["money_flow"]["available"] is True
assert detail["money_flow"]["net_million"] == 12.5
assert detail["event"] == {
"status": "涨停", "reason": "银行板块走强", "streak": 1,
"first_time": "09:35", "last_time": "14:20", "open_times": 0,
"seal_amount": None,
}
def test_minute_chart_contract_has_real_session_bounds_and_hides_source(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))