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 }) => {
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import unittest
from chart_data_provider import ChartDataError, EastmoneyChartClient
from server import DashboardService
class FakeChartClient(EastmoneyChartClient):
def __init__(self) -> None:
super().__init__(cache_ttl_seconds=20)
self.requests: list[tuple[str, dict[str, str]]] = []
def _request_json(self, url, params, referer):
self.requests.append((url, params))
if "trends2" in url:
return {
"data": {
"code": params["secid"].split(".", 1)[1],
"name": "测试行情",
"preClose": 10.0,
"trends": [
"2026-07-24 09:30,10.10,10.20,10.30,10.00,100,1020.00,10.200",
"2026-07-24 09:31,10.20,10.15,10.25,10.10,80,812.00,10.178",
],
}
}
return {
"data": {
"diff": [
{"f12": "BK0474", "f14": "保险Ⅱ"},
{"f12": "BK1040", "f14": "中药Ⅱ"},
]
}
}
class ChartDataProviderTests(unittest.TestCase):
def setUp(self) -> None:
EastmoneyChartClient._cache.clear()
EastmoneyChartClient._board_catalog.clear()
EastmoneyChartClient._board_catalog_at = 0
self.client = FakeChartClient()
def test_stock_intraday_maps_market_and_parses_points(self):
payload = self.client.stock_intraday("601318")
self.assertEqual(self.client.requests[0][1]["secid"], "1.601318")
self.assertEqual(payload["trade_date"], "2026-07-24")
self.assertEqual(payload["points"][0]["time"], "09:30")
self.assertEqual(payload["points"][0]["average"], 10.2)
def test_short_cache_avoids_duplicate_hover_requests(self):
self.client.stock_intraday("002141")
self.client.stock_intraday("002141")
trend_requests = [item for item in self.client.requests if "trends2" in item[0]]
self.assertEqual(len(trend_requests), 1)
def test_index_and_board_use_the_same_chart_shape(self):
index = self.client.index_intraday("000001.SH")
board = self.client.board_intraday("BK0474")
self.assertEqual(index["points"][1]["close"], 10.15)
self.assertEqual(board["points"][1]["volume"], 80.0)
secids = [params["secid"] for url, params in self.client.requests if "trends2" in url]
self.assertIn("1.000001", secids)
self.assertIn("90.BK0474", secids)
def test_invalid_identifier_is_rejected(self):
with self.assertRaises(ChartDataError):
self.client.stock_intraday("abc")
class ChartServiceStub:
@staticmethod
def _payload(code: str, name: str):
return {
"code": code,
"name": name,
"trade_date": "2026-07-24",
"previous_close": 10,
"points": [{"date": "2026-07-24", "time": "09:30", "close": 10.1}],
}
def stock_intraday(self, code):
return self._payload(code, "测试股票")
def index_intraday(self, identifier):
return self._payload(identifier, "上证指数")
def board_intraday(self, identifier, name=""):
return self._payload("BK0474", name)
class ChartDirectoryStub:
@staticmethod
def get_data_snapshot(kind, cache_key):
if (kind, cache_key) != ("search_directory", "ths"):
return None
return {
"schema_version": 2,
"items": [
{"id": "881107.TI", "name": "保险", "type": "sector"},
{"id": "885728.TI", "name": "人工智能", "type": "theme"},
],
}
class IntradayChartServiceTests(unittest.TestCase):
def setUp(self):
self.service = DashboardService.__new__(DashboardService)
self.service.chart_data = ChartServiceStub()
self.service.database = ChartDirectoryStub()
def test_stock_index_sector_and_theme_share_display_only_contract(self):
cases = (
("stock", "601318"),
("index", "000001.SH"),
("sector", "881107.TI"),
("theme", "885728.TI"),
)
for entity_type, identifier in cases:
with self.subTest(entity_type=entity_type):
payload = self.service.get_intraday_chart(entity_type, identifier)
self.assertEqual(payload["entity"]["type"], entity_type)
self.assertEqual(payload["meta"]["trade_date"], "2026-07-24")
self.assertEqual(len(payload["points"]), 1)
self.assertNotIn("source", payload["meta"])
if __name__ == "__main__":
unittest.main()
+79 -1
View File
@@ -43,7 +43,39 @@ class FrontendContractTests(unittest.TestCase):
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html))
self.assertEqual(views, navigation)
self.assertEqual(len(views), 13)
self.assertEqual(len(views), 16)
def test_market_discovery_views_are_wired_end_to_end(self):
for view_id in ("auctionView", "themeLibraryView", "popularityView"):
self.assertIn(f'id="{view_id}"', self.html)
self.assertIn(f'data-view="{view_id}"', self.html)
for endpoint in ("/api/auction?", "/api/themes?", "/api/themes/detail?", "/api/popularity?"):
self.assertIn(endpoint, self.script)
for field in (
"auction_change", "auction_amount_million",
"auction_turnover_rate", "auction_volume_ratio",
):
self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8"))
def test_auction_navigation_and_frontend_pools_follow_product_order(self):
rotation = self.html.index('data-view="rotationView"')
auction = self.html.index('data-view="auctionView"')
themes = self.html.index('data-view="themeLibraryView"')
self.assertLess(rotation, auction)
self.assertLess(auction, themes)
for dataset in ("focus", "onePrice", "watchlist", "all"):
self.assertIn(f'data-auction-dataset="{dataset}"', self.html)
for filter_name in ("all", "above", "matched", "below"):
self.assertIn(f'data-auction-filter="{filter_name}"', self.html)
self.assertNotIn('data-auction-filter="strong"', self.html)
self.assertNotIn('data-auction-filter="limit"', self.html)
self.assertIn('id="auctionThemeCarry"', self.html)
self.assertIn('id="auctionAmountTrend"', self.html)
self.assertIn('id="auctionNewsTitle"', self.html)
self.assertIn('id="auctionWorkspaceTitle"', self.html)
self.assertIn('id="auctionExpectationFilterbar"', self.html)
self.assertIn('class="auction-news-entry"', self.html)
self.assertIn('class="disabled-status">暂不可用', self.html)
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
@@ -81,6 +113,52 @@ class FrontendContractTests(unittest.TestCase):
self.assertNotIn("trade_date", preview_loader)
self.assertNotIn("elements.tradeDate.value", preview_loader)
def test_daily_rising_candles_are_fully_hollow_without_crossing_wicks(self):
start = self.script.index("function drawCandlestick")
end = self.script.index("function drawPriceChart", start)
candle = self.script[start:end]
self.assertIn("context.lineTo(x, bodyTop);", candle)
self.assertIn("context.moveTo(x, bodyBottom);", candle)
self.assertIn("context.lineTo(x, lowY);", candle)
self.assertIn("context.fillStyle = CHART_BACKGROUND;", candle)
self.assertIn("context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);", candle)
self.assertNotIn("context.lineTo(x, lowY);\n context.stroke();\n const openY", candle)
def test_stock_hover_intraday_draws_average_without_source_label(self):
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawDailyPreviewChart", start)
chart = self.script[start:end]
self.assertIn("point.average", chart)
self.assertIn('context.strokeStyle = "#b7791f";', chart)
self.assertIn('intraday_trade_date || payload.meta?.trade_date', self.script)
self.assertIn('(payload.intraday || []).length ? "最新分时 · 1分钟"', self.script)
def test_hover_prefers_daily_and_intraday_uses_centered_zero_axis(self):
self.assertIn('stockPreviewChart: "daily"', self.script)
self.assertIn('state.stockPreviewChart = "daily";', self.script)
self.assertIn('selectStockPreviewChart("daily");', self.script)
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawIntradayPreviewChart", start)
intraday = self.script[start:end]
self.assertIn("Math.abs(maximum - previousClose)", intraday)
self.assertIn("Math.abs(previousClose - minimum)", intraday)
self.assertIn('context.fillText("0.00%"', intraday)
self.assertIn('label: "09:30"', intraday)
self.assertIn('label: "11:30 / 13:00"', intraday)
self.assertIn('label: "15:00"', intraday)
self.assertIn("intradayMinuteOffset(points[index]?.time) / 240", intraday)
def test_detail_dialogs_offer_lazy_daily_and_intraday_modes(self):
self.assertIn('data-stock-detail-chart="daily"', self.html)
self.assertIn('data-stock-detail-chart="intraday"', self.html)
self.assertIn('data-entity-detail-chart="daily"', self.html)
self.assertIn('data-entity-detail-chart="intraday"', self.html)
self.assertIn('/api/chart/intraday?', self.script)
self.assertIn('drawIntradayCanvas(elements.priceChart', self.script)
self.assertIn('drawIntradayCanvas(elements.entityDetailChart', self.script)
self.assertIn('state.stockDetailChartMode === "intraday"', self.script)
self.assertIn('state.entityDetailChartMode === "intraday"', self.script)
def test_membership_copy_includes_review_assistant_access(self):
self.assertIn("复盘助手仅对会员开放", self.html)
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
+3 -1
View File
@@ -84,7 +84,9 @@ class GlobalSearchTests(unittest.TestCase):
self.assertIn('id="globalSearchButton"', html)
self.assertIn('id="globalSearchDialog"', html)
self.assertIn('id="entityDetailDialog"', html)
self.assertIn("日 K 与成交量", html)
self.assertIn("行情走势", html)
self.assertIn('data-entity-detail-chart="daily"', html)
self.assertIn('data-entity-detail-chart="intraday"', html)
self.assertIn('event.key.toLowerCase() !== "k"', script)
self.assertIn('openStock(item.id, { code: item.code', script)
self.assertNotIn('include_notes', script)
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from database import ReviewDatabase
from market_insights import MarketInsightsService
from screener import FACTOR_FIELDS, ScreenerEngine
class FakeMarketClient:
def resolve_trade_context(self, requested: str):
value = str(requested).replace("-", "")
return value, "20260723"
def query(self, api_name, params=None, fields=""):
params = params or {}
date = params.get("trade_date", "")
if api_name == "stock_basic":
return [
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"},
{"ts_code": "000002.SZ", "name": "万科A", "industry": "房地产", "market": "主板", "list_date": "19910129"},
]
if api_name == "stk_auction":
if date == "20260724":
return []
return [
{"ts_code": "000001.SZ", "trade_date": date, "price": 10.5, "pre_close": 10, "vol": 20000, "amount": 5_000_000, "turnover_rate": 0.12, "volume_ratio": 1.8},
{"ts_code": "000002.SZ", "trade_date": date, "price": 9.8, "pre_close": 10, "vol": 10000, "amount": 2_000_000, "turnover_rate": 0.05, "volume_ratio": 0.8},
]
if api_name == "stk_limit":
return [
{"ts_code": "000001.SZ", "trade_date": date, "up_limit": 11, "down_limit": 9},
{"ts_code": "000002.SZ", "trade_date": date, "up_limit": 11, "down_limit": 9},
]
if api_name == "ths_index":
return [{"ts_code": "885001.TI", "name": "人工智能", "count": 2, "exchange": "A", "list_date": "20200101", "type": "N"}]
if api_name == "ths_daily":
if params.get("ts_code"):
return [
{"ts_code": "885001.TI", "trade_date": "20260722", "open": 99, "high": 102, "low": 98, "close": 101, "pct_change": 1, "vol": 100},
{"ts_code": "885001.TI", "trade_date": "20260723", "open": 101, "high": 104, "low": 100, "close": 103, "pct_change": 1.98, "vol": 120},
]
if date == "20260724":
return []
return [{"ts_code": "885001.TI", "trade_date": date, "close": 103, "pct_change": 1.98, "vol": 120, "turnover_rate": 2.3}]
if api_name == "ths_member":
return [
{"ts_code": "885001.TI", "con_code": "000001.SZ", "con_name": "平安银行"},
{"ts_code": "885001.TI", "con_code": "000002.SZ", "con_name": "万科A"},
]
if api_name == "daily":
return [
{"ts_code": "000001.SZ", "trade_date": date, "open": 10, "high": 11, "low": 9.8, "close": 10.5, "pct_chg": 5, "vol": 100, "amount": 200000},
{"ts_code": "000002.SZ", "trade_date": date, "open": 10, "high": 10, "low": 9.7, "close": 9.8, "pct_chg": -2, "vol": 100, "amount": 100000},
]
if api_name == "ths_hot":
if date == "20260724":
return []
return [
{"trade_date": date, "data_type": "热股", "ts_code": "000001.SZ", "ts_name": "平安银行", "rank": 1, "pct_change": 5, "current_price": 10.5, "hot": 1000, "concept": '["银行"]'},
{"trade_date": date, "data_type": "概念板块", "ts_code": "885001.TI", "ts_name": "人工智能", "rank": 1, "pct_change": 1.98, "hot": 800},
]
if api_name == "dc_hot":
if date == "20260724":
return []
return [{"trade_date": date, "data_type": "A股市场", "ts_code": "000001.SZ", "ts_name": "平安银行", "rank": 3, "pct_change": 5, "current_price": 10.5}]
return []
class MarketInsightsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.service = MarketInsightsService(
self.database,
FakeMarketClient(),
now_provider=lambda: datetime(
2026, 7, 24, 9, 20, tzinfo=timezone(timedelta(hours=8))
),
)
def tearDown(self):
self.temp.cleanup()
def test_auction_falls_back_and_normalizes_factors(self):
payload = self.service.auction_center("20260724")
self.assertEqual(payload["meta"]["trade_date"], "2026-07-23")
self.assertTrue(payload["meta"]["carried_forward"])
self.assertEqual(payload["meta"]["phase"], "observing")
self.assertEqual(payload["summary"]["stock_count"], 2)
self.assertEqual(payload["rows"][0]["amount_million"], 5)
self.assertEqual(payload["rows"][0]["change"], 5)
self.assertEqual(payload["rows"][0]["expectation"], "超预期")
self.assertEqual(set(payload["expectations"]), {"超预期", "符合预期", "低于预期"})
self.assertFalse(payload["news_feedback"]["available"])
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
self.assertEqual(payload["focus_rows"][0]["code"], "000001")
def test_real_limit_price_is_isolated_from_scored_candidates(self):
class OnePriceClient(FakeMarketClient):
def query(self, api_name, params=None, fields=""):
if api_name == "stk_limit":
return [
{"ts_code": "000001.SZ", "trade_date": "20260723", "up_limit": 10.5, "down_limit": 9},
{"ts_code": "000002.SZ", "trade_date": "20260723", "up_limit": 11, "down_limit": 9},
]
return super().query(api_name, params, fields)
service = MarketInsightsService(self.database, OnePriceClient(), self.service._now_provider)
payload = service.auction_center("20260724", force=True)
self.assertEqual([row["code"] for row in payload["one_price_rows"]], ["000001"])
self.assertNotIn("000001", {row["code"] for row in payload["rows"]})
def test_core_broken_pool_and_watchlist_are_kept_separate(self):
self.database.save_snapshot(
"20260723",
"test",
{
"limits": [{"code": "000001", "name": "平安银行", "sector": "银行", "streak": 3, "amount_billion": 8}],
"broken": [{"code": "000002", "name": "万科A", "sector": "房地产", "streak": 1}],
"sectors": [{"name": "银行", "count": 1, "leader": "平安银行"}],
},
)
user = self.database.create_user("auction-user", "salt", "hash")
self.database.save_watchlist(user["id"], "000002", "万科A", "房地产", "red")
payload = self.service.auction_center("20260724", force=True, user_id=user["id"])
rows = {row["code"]: row for row in payload["rows"]}
self.assertIn("昨日炸板", rows["000002"]["candidate_sources"])
self.assertIn("三板以上", rows["000001"]["core_tags"])
self.assertIn("000001", {row["code"] for row in payload["focus_rows"]})
self.assertEqual([row["code"] for row in payload["watchlist_rows"]], ["000002"])
anonymous = self.service.auction_center("20260724", user_id=0)
self.assertEqual(anonymous["watchlist_rows"], [])
def test_selection_window_does_not_disguise_previous_day_as_current(self):
service = MarketInsightsService(
self.database,
FakeMarketClient(),
now_provider=lambda: datetime(
2026, 7, 24, 9, 26, tzinfo=timezone(timedelta(hours=8))
),
)
payload = service.auction_center("20260724", force=True)
self.assertEqual(payload["meta"]["phase"], "selection")
self.assertFalse(payload["meta"]["available"])
self.assertFalse(payload["meta"]["carried_forward"])
self.assertEqual(payload["rows"], [])
def test_theme_library_detail_and_popularity(self):
library = self.service.theme_library("20260724")
self.assertEqual(library["meta"]["trade_date"], "2026-07-23")
self.assertEqual(library["items"][0]["hot_rank"], 1)
detail = self.service.theme_detail("885001.TI", "20260724")
self.assertEqual(detail["summary"]["member_count"], 2)
self.assertEqual(detail["members"][0]["code"], "000001")
hot = self.service.popularity("20260724")
self.assertEqual(hot["summary"]["dual_count"], 1)
self.assertEqual(hot["combined"][0]["name"], "平安银行")
class AuctionScreenerFactorTests(unittest.TestCase):
def test_auction_fields_are_available_to_formula_and_factor_rows(self):
with tempfile.TemporaryDirectory() as temporary:
database = ReviewDatabase(Path(temporary) / "review.db")
database.upsert_stock_master([
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"}
])
dates = [f"202606{day:02d}" for day in range(1, 22)]
database.upsert_daily_bars([
{"ts_code": "000001.SZ", "trade_date": trade_date, "open": 10, "high": 11, "low": 9, "close": 10 + index * 0.1, "pct_chg": 1, "vol": 1000 + index, "amount": 200000}
for index, trade_date in enumerate(dates)
])
database.upsert_daily_indicators([
{"ts_code": "000001.SZ", "trade_date": dates[-1], "turnover_rate": 2, "volume_ratio": 1.2, "circ_mv": 100000, "total_mv": 120000}
])
database.upsert_auction_factors([
{"ts_code": "000001.SZ", "trade_date": dates[-1], "price": 12.6, "pre_close": 12, "amount": 8_000_000, "vol": 30000, "turnover_rate": 0.18, "volume_ratio": 2.1}
])
rows, actual_date = ScreenerEngine(database).build_factors(dates[-1])
self.assertEqual(actual_date, dates[-1])
self.assertEqual(rows[0]["auction_change"], 5)
self.assertEqual(rows[0]["auction_amount_million"], 8)
self.assertEqual(rows[0]["auction_volume_ratio"], 2.1)
self.assertIn("auction_change", FACTOR_FIELDS)
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import threading
import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
from server import DashboardService
class DetailDatabaseStub:
@staticmethod
def list_watchlist(user_id):
return []
@staticmethod
def list_notes(user_id, code=""):
return []
class RealtimeClientStub:
quote_calls = 0
def __init__(self, token):
self.token = token
@staticmethod
def resolve_trade_context(requested_date):
return requested_date, requested_date
@classmethod
def realtime_stock_quote(cls, ts_code, reference_date=""):
cls.quote_calls += 1
return {
"name": "测试股票",
"sector": "测试行业",
"price": 9.8,
"change": -2.0,
"open": 10.1,
"high": 10.2,
"low": 9.7,
"volume": 123400,
"amount_billion": 1.25,
"turnover_rate": 3.5,
}
class FixedMarketDatetime(datetime):
fixed_now = datetime.now().astimezone().replace(hour=10, minute=30, second=0, microsecond=0)
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class StockDetailRealtimeTests(unittest.TestCase):
def setUp(self):
self.service = DashboardService.__new__(DashboardService)
self.service._system_credentials = {"tushare_token": "test-token"}
self.service.database = DetailDatabaseStub()
self.service._request_context = threading.local()
self.service._request_context.user_id = 1
RealtimeClientStub.quote_calls = 0
def test_today_detail_merges_rt_quote_without_mutating_daily_cache(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
yesterday = (FixedMarketDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d")
cached = {
"meta": {"trade_date": today, "source": "tushare"},
"stock": {"code": "002141", "name": "旧名称", "price": 10, "change": 7.1},
"prices": [
{
"trade_date": yesterday,
"open": 9.5,
"high": 10.1,
"low": 9.4,
"close": 10,
"change": 7.1,
"volume": 100,
}
],
"moneyflow": {},
}
with patch("server.datetime", FixedMarketDatetime), patch(
"server.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(cached, "002141", today)
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
self.assertTrue(result["meta"]["realtime"])
self.assertEqual(result["stock"]["price"], 9.8)
self.assertEqual(result["stock"]["change"], -2.0)
self.assertEqual(result["prices"][-1]["change"], -2.0)
self.assertEqual(result["prices"][-1]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
self.assertEqual(cached["stock"]["change"], 7.1)
self.assertEqual(len(cached["prices"]), 1)
self.assertEqual(RealtimeClientStub.quote_calls, 1)
def test_historical_detail_never_requests_realtime_quote(self):
historical = (FixedMarketDatetime.fixed_now - timedelta(days=5)).strftime("%Y%m%d")
payload = {
"meta": {"trade_date": historical, "source": "tushare"},
"stock": {"code": "002141", "price": 10, "change": 1.2},
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
}
with patch("server.datetime", FixedMarketDatetime), patch(
"server.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2)
self.assertNotIn("realtime", result["meta"])
self.assertEqual(RealtimeClientStub.quote_calls, 0)
if __name__ == "__main__":
unittest.main()