feat: expand market discovery and auction workflow
This commit is contained in:
+220
-4
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user