feat: expand market discovery and auction workflow

This commit is contained in:
leefer
2026-07-24 17:32:28 +08:00
parent fde2728a86
commit 2d2a3aa5e5
46 changed files with 32992 additions and 168 deletions
+220 -4
View File
@@ -114,13 +114,50 @@ async function mockApplication(page, authSession = session()) {
else if (url.pathname === "/api/dashboard") payload = dashboard;
else if (url.pathname === "/api/stock/002141/preview") {
payload = {
meta: { trade_date: "2026-07-23", realtime: false, intraday_notice: "" },
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
prices: [
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
],
intraday: [],
intraday: [
{ date: "2026-07-24", time: "09:30", open: 10.20, high: 10.24, low: 10.18, close: 10.22, volume: 100, average: 10.22 },
{ date: "2026-07-24", time: "09:31", open: 10.22, high: 10.30, low: 10.21, close: 10.28, volume: 130, average: 10.25 },
{ date: "2026-07-24", time: "09:32", open: 10.28, high: 10.29, low: 10.20, close: 10.23, volume: 90, average: 10.24 },
{ date: "2026-07-24", time: "09:33", open: 10.23, high: 10.34, low: 10.22, close: 10.32, volume: 160, average: 10.27 },
{ date: "2026-07-24", time: "09:34", open: 10.32, high: 10.36, low: 10.29, close: 10.34, volume: 120, average: 10.28 },
],
};
} else if (url.pathname === "/api/stock/002141") {
payload = {
meta: { trade_date: "2026-07-23", realtime: false },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
prices: [
{ trade_date: "2026-07-22", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 },
{ trade_date: "2026-07-23", open: 10.3, high: 10.9, low: 10.2, close: 10.8, volume: 1200 },
],
moneyflow: {},
notes: [],
};
} else if (url.pathname === "/api/search/detail") {
payload = {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 },
series: [
{ trade_date: "2026-07-22", open: 3750, high: 3790, low: 3740, close: 3780, volume: 1000 },
{ trade_date: "2026-07-23", open: 3782, high: 3810, low: 3770, close: 3800, volume: 1200 },
],
metrics: [],
};
} else if (url.pathname === "/api/chart/intraday") {
payload = {
meta: { trade_date: "2026-07-24", previous_close: 10.1 },
entity: { id: url.searchParams.get("id"), type: url.searchParams.get("type") },
points: [
{ date: "2026-07-24", time: "09:30", open: 10.10, high: 10.18, low: 10.08, close: 10.15, volume: 100, average: 10.15 },
{ date: "2026-07-24", time: "09:31", open: 10.15, high: 10.24, low: 10.14, close: 10.22, volume: 130, average: 10.18 },
{ date: "2026-07-24", time: "09:32", open: 10.22, high: 10.23, low: 10.16, close: 10.18, volume: 90, average: 10.18 },
],
};
} else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
else if (url.pathname === "/api/alerts") {
@@ -173,6 +210,56 @@ async function mockApplication(page, authSession = session()) {
};
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
else if (url.pathname === "/api/auction") {
payload = {
meta: { trade_date: "2026-07-22", carried_forward: false, phase: "finalized", available: true, actionable: false },
summary: { stock_count: 3, candidate_count: 1, focus_count: 1, one_price_count: 1, up_count: 2, down_count: 1, limit_open_count: 1, strong_open_count: 2, median_change: 1.2, amount_billion: 2.5, amount_change_previous: 12.5, amount_change_5d: 8.2 },
expectations: { "超预期": 1, "符合预期": 0, "低于预期": 0 },
candidate_meta: { baseline_date: "2026-07-21" },
themes: {
carry: [{ name: "Test Sector", status: "强承接", prior_limit_count: 2, leader: "Test Stock", matched_count: 1, median_change: 4.2, positive_rate: 100, amount_million: 15 }],
new_themes: [{ name: "人工智能", stock_count: 2, median_change: 3.5, amount_million: 26, leaders: ["Test Stock"] }],
},
amount_history: [
{ trade_date: "2026-07-21", amount_billion: 2.2, stock_count: 2 },
{ trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 },
],
news_feedback: { available: false, message: "隔夜消息反馈暂不可用", detail: "待稳定的新闻与公告数据接入后开放" },
focus_rows: [
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
],
one_price_rows: [
{ code: "000001", name: "Limit Stock", sector: "Test Sector", change: 10, price: 11, amount_million: 8, volume_ratio: 3.2, source_label: "昨日涨停", prior_streak: 3, core_tags: ["三板以上"], is_market_core: true, is_one_price: true },
],
watchlist_rows: [
{ code: "000002", name: "Watch Stock", sector: "Bank", change: -1.2, price: 9.88, amount_million: 3, volume_ratio: 0.9, expectation: "符合预期", expected_change: 0, attention_score: 32.5, core_tags: [], expectation_reason: "自选观察;竞价反馈接近个人观察基准", is_watchlist: true, available: true },
],
watchlist_missing_count: 0,
rows: [
{ code: "002141", name: "Test Stock", sector: "Test Sector", change: 4.2, price: 10.2, amount_million: 15, volume_ratio: 1.8, turnover_rate: 0.12, source_label: "昨日涨停 · 同花顺热榜", expectation: "超预期", expected_change: 2.2, attention_score: 88.5, core_tags: ["人气前5"], expectation_reason: "昨日首板;竞价涨幅高于预期中枢2.0个百分点,量比1.80" },
],
};
} else if (url.pathname === "/api/themes") {
payload = {
meta: { trade_date: "2026-07-22", carried_forward: false },
summary: { theme_count: 1, quoted_count: 1, up_count: 1, down_count: 0, hot_count: 1 },
items: [{ code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }],
};
} else if (url.pathname === "/api/themes/detail") {
payload = {
meta: { trade_date: "2026-07-22" },
theme: { code: "885728.TI", name: "人工智能", member_count: 1, change: 2.2, turnover_rate: 3.1 },
summary: { member_count: 1, quoted_count: 1, up_count: 1, down_count: 0 },
series: [
{ trade_date: "2026-07-21", open: 100, high: 104, low: 99, close: 103, change: 3, volume: 1000 },
{ trade_date: "2026-07-22", open: 103, high: 106, low: 102, close: 105, change: 1.94, volume: 1200 },
],
members: [{ code: "002141", name: "Test Stock", change: 2.4, price: 10.8, amount_billion: 3.2, has_quote: true }],
};
} else if (url.pathname === "/api/popularity") {
const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "Test Stock", change: 2.4, price: 10.8, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["人工智能"], dual_source: true };
payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { ths_count: 1, dc_count: 1, dual_count: 1 }, combined: [hot], ths: [{ ...hot, rank: 1 }], dc: [{ ...hot, rank: 2 }] };
}
else if (url.pathname === "/api/screener/setup") {
payload = {
trade_date: "20260722",
@@ -253,8 +340,8 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await page.locator("#closeAssistantDialog").click();
const views = [
"sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "dragonView", "screenerView",
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView",
"mentorView", "heavenView", "reviewWorkspaceView",
];
for (const view of views) {
@@ -267,6 +354,52 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("auction, themes and popularity reuse stock detail interactions", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="auctionView"]').first().click();
await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格");
await expect(page.locator("#auctionRefreshButton")).toBeHidden();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("人气前5");
await page.locator('[data-auction-filter="above"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionAboveCount")).toHaveText("1");
await expect(page.locator("#auctionThemeCarry")).toContainText("强承接");
await expect(page.locator("#auctionAmountValue")).toHaveText("2.50 亿");
await expect(page.locator("#auctionNewsTitle")).toHaveText("隔夜消息反馈");
await expect(page.locator(".auction-news-entry")).toContainText("暂不可用");
await page.locator('[data-auction-dataset="onePrice"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("三板以上");
await expect(page.locator("#auctionExpectationFilterbar")).toBeHidden();
await page.locator('[data-auction-dataset="watchlist"]').click();
await expect(page.locator("#auctionTableBody tr")).toHaveCount(1);
await expect(page.locator("#auctionTableBody")).toContainText("Watch Stock");
await page.locator('[data-auction-dataset="focus"]').click();
await page.evaluate(() => {
state.auctionData.meta = { phase: "selection", available: false, actionable: true };
state.auctionData.rows = [];
state.auctionData.focus_rows = [];
renderAuctionCenter();
});
await expect(page.locator("#auctionPhaseTitle")).toHaveText("等待最终竞价");
await expect(page.locator("#auctionRefreshButton")).toBeVisible();
await expect(page.locator("#auctionEmpty")).toContainText("正在等待 9:25 最终竞价数据");
await page.locator('[data-view="themeLibraryView"]').first().click();
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1);
await expect(page.locator("#themeMemberTableBody tr")).toHaveCount(1);
await expect(page.locator("#themeDetailName")).toHaveText("人工智能");
await page.locator('[data-view="popularityView"]').first().click();
await expect(page.locator("#popularityTableBody tr")).toHaveCount(1);
await expect(page.locator("#popularityTableBody")).toContainText("双榜共识");
await page.locator("#popularityTableBody tr").click();
await expect(page.locator("#stockDialog")).toBeVisible();
});
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
await mockApplication(page, session("user", false));
await page.goto("/index.html");
@@ -298,6 +431,89 @@ test("stock hover preview ignores the selected historical date", async ({ page }
expect(requestUrl.searchParams.has("trade_date")).toBe(false);
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-23");
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
await page.locator('[data-preview-chart="intraday"]').click();
await expect(page.locator("#stockPreviewDate")).toHaveText("2026-07-24");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
const canvasColors = await page.locator("#stockPreviewChart").evaluate((canvas) => {
const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data;
const colors = new Set();
for (let index = 0; index < pixels.length; index += 16) {
if (pixels[index + 3]) colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`);
}
return colors.size;
});
expect(canvasColors).toBeGreaterThan(4);
});
test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
const pixels = await page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 40;
canvas.height = 80;
const context = canvas.getContext("2d");
context.fillStyle = CHART_BACKGROUND;
context.fillRect(0, 0, 40, 80);
const priceY = (value) => 90 - value * 8;
drawCandlestick(context, 20, { high: 10, close: 8, open: 6, low: 4 }, priceY, 10);
const read = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data);
const reddest = (left, top, width, height) => {
const data = context.getImageData(left, top, width, height).data;
let selected = [0, 0, 0, 0];
for (let index = 0; index < data.length; index += 4) {
const pixel = [data[index], data[index + 1], data[index + 2], data[index + 3]];
if (pixel[0] - pixel[1] > selected[0] - selected[1]) selected = pixel;
}
return selected;
};
return {
upperWick: reddest(19, 10, 3, 16),
bodyCenter: read(20, 34),
lowerWick: reddest(19, 43, 3, 17),
bodyBorder: reddest(14, 26, 3, 17),
};
});
for (const redPixel of [pixels.upperWick, pixels.lowerWick, pixels.bodyBorder]) {
expect(redPixel[0] - redPixel[1]).toBeGreaterThan(40);
expect(redPixel[0] - redPixel[2]).toBeGreaterThan(40);
}
expect(pixels.bodyCenter.slice(0, 3)).toEqual([251, 252, 253]);
});
test("stock and market detail dialogs switch from daily K to intraday", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.evaluate(() => openStock("002141", { code: "002141", name: "Test Stock", sector: "Test Sector" }));
await page.locator('[data-stock-detail-chart="intraday"]').click();
await expect(page.locator("#chartSource")).toHaveText("分时 · 2026-07-24");
await expect(page.locator('[data-stock-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true");
await page.locator('[data-stock-detail-chart="daily"]').click();
await expect(page.locator("#chartSource")).toContainText("日 K 行情");
await page.locator("#closeStockDialog").click();
await page.evaluate(() => openEntityDetail({ id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数" }));
await page.locator('[data-entity-detail-chart="intraday"]').click();
await expect(page.locator("#entityDetailDate")).toHaveText("分时 · 2026-07-24");
await expect(page.locator('[data-entity-detail-chart="intraday"]')).toHaveAttribute("aria-pressed", "true");
const colors = await page.locator("#entityDetailChart").evaluate((canvas) => {
const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data;
return new Set(Array.from({ length: Math.floor(pixels.length / 16) }, (_, index) => {
const offset = index * 16;
return `${pixels[offset]},${pixels[offset + 1]},${pixels[offset + 2]},${pixels[offset + 3]}`;
})).size;
});
expect(colors).toBeGreaterThan(4);
const offsets = await page.evaluate(() => [
intradayMinuteOffset("09:30"),
intradayMinuteOffset("11:30"),
intradayMinuteOffset("13:00"),
intradayMinuteOffset("15:00"),
]);
expect(offsets).toEqual([0, 120, 120, 240]);
});
test("saved daily fortune opens in the reading dialog without regenerating", async ({ page }) => {