feat: complete strategy and market data improvements

This commit is contained in:
leefer
2026-07-29 16:50:40 +08:00
parent c30d2107b3
commit 0030bb8cc1
18 changed files with 1622 additions and 162 deletions
+136 -21
View File
@@ -135,12 +135,20 @@ async function mockApplication(page, authSession = session(), options = {}) {
if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
options.dashboardTradeDates ||= [];
options.dashboardTradeDates.push(url.searchParams.get("trade_date"));
if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
}
payload = dashboard;
if (options.echoDashboardDate) {
const requestedDate = url.searchParams.get("trade_date");
payload = { ...dashboard, meta: { ...dashboard.meta, trade_date: requestedDate, requested_date: requestedDate } };
} else payload = dashboard;
}
else if (url.pathname === "/api/stock/002141/preview") {
if (options.previewDelay) {
await new Promise((resolve) => setTimeout(resolve, options.previewDelay));
}
payload = {
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 },
@@ -168,7 +176,16 @@ async function mockApplication(page, authSession = session(), options = {}) {
notes: [],
};
} else if (url.pathname === "/api/search/detail") {
payload = {
const theme = url.searchParams.get("type") === "theme";
payload = theme ? {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "885728.TI", code: "885728.TI", name: "人工智能", type: "theme", type_label: "题材", value: 1280, change: 2.2 },
series: [
{ trade_date: "2026-07-22", open: 1220, high: 1260, low: 1210, close: 1250, volume: 1000 },
{ trade_date: "2026-07-23", open: 1255, high: 1290, low: 1248, close: 1280, volume: 1200 },
],
metrics: [],
} : {
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: [
@@ -486,6 +503,27 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("fresh visits default to the latest date and sentiment cycle", async ({ page }) => {
const options = { echoDashboardDate: true };
await mockApplication(page, session("admin", true), options);
await page.goto("/index.html?date=2026-07-28");
const today = await page.evaluate(() => todayString());
await expect(page.locator("#tradeDate")).toHaveValue(today);
await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/);
await expect(page.locator('[data-view="sentimentCycleView"]')).toHaveClass(/active/);
expect(options.dashboardTradeDates.at(-1)).toBe(today);
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
await page.evaluate(() => {
const input = document.querySelector("#tradeDate");
input.value = "2026-07-28";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect.poll(() => options.dashboardTradeDates.at(-1)).toBe("2026-07-28");
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
});
test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
@@ -564,7 +602,7 @@ test("night mode covers the application shell and persists across reloads", asyn
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
});
test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => {
test("collapsed overview and sentiment layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
@@ -591,49 +629,37 @@ test("collapsed overview and sentiment decision layout keep a single current rea
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await expect(page.locator("#sentimentStageGuideTitle")).toHaveText("判定口径");
await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1);
await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible();
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const currentGuide = document.querySelector("[data-sentiment-stage].current");
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const label = document.querySelector(".sentiment-block .metric-label");
const status = document.querySelector(".sentiment-block .sentiment-text");
const rangeHeader = document.querySelector(".sentiment-stage-guide-head > span:nth-child(3)").getBoundingClientRect();
const rangeValue = currentGuide.querySelector(".stage-range").getBoundingClientRect();
const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label"));
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
mainAligned: Math.abs(guide.x - trend.x) < 1 && Math.abs(guide.width - trend.width) < 1 && guide.top > trend.bottom,
columnsAligned: Math.abs(trend.top - summary.top) < 1,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailVisible: detail.top < innerHeight,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height,
guideRowHeight: currentGuide.getBoundingClientRect().height,
guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)",
sameType: labelStyle.fontSize === statusStyle.fontSize
&& labelStyle.fontWeight === statusStyle.fontWeight
&& labelStyle.lineHeight === statusStyle.lineHeight,
sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1,
noStatusOffset: statusStyle.marginTop === "0px",
rangeAligned: Math.abs(rangeHeader.right - rangeValue.right) < 1,
};
});
expect(alignment.mainAligned).toBe(true);
expect(alignment.columnsAligned).toBe(true);
expect(alignment.railAligned).toBe(true);
expect(alignment.detailVisible).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.guideRowHeight).toBeLessThanOrEqual(52);
expect(alignment.guideIsWhite).toBe(true);
expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true);
expect(alignment.rangeAligned).toBe(true);
const pageFrames = {};
for (const [view, headSelector] of [
@@ -946,6 +972,29 @@ test("market ladder transfers tier bands, sorting and structural insights", asyn
await page.locator('[data-ladder-level="2"]').click();
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只");
const ladderOverflow = await page.evaluate(() => {
const group = state.dashboard.ladders.find((item) => item.level === 2);
group.stocks = Array.from({ length: 48 }, (_, index) => ({
code: `001${String(index).padStart(3, "0")}`,
name: `二板扩展${index + 1}`,
sector: "电网设备",
first_time: "09:30:00",
open_times: index % 4,
amount_billion: 1.2,
}));
group.count = group.stocks.length;
state.expandedLadderLevels.add(2);
renderLadderBoard(state.dashboard.ladders);
const main = document.querySelector(".app-main");
return {
clientHeight: main.clientHeight,
scrollHeight: main.scrollHeight,
overflowY: getComputedStyle(main).overflowY,
};
});
expect(ladderOverflow.overflowY).toBe("auto");
expect(ladderOverflow.scrollHeight).toBeGreaterThan(ladderOverflow.clientHeight);
});
test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => {
@@ -1159,7 +1208,20 @@ test("theme library preserves the full master-detail workflow in its redesigned
await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5);
await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true");
expect(await page.locator("#themeDetailChart").evaluate((canvas) => canvas.toDataURL().length)).toBeGreaterThan(100);
await expect(page.locator("#themeDetailChart")).toHaveCount(0);
const themePreviewRequest = page.waitForRequest((request) => request.url().includes("/api/search/detail?") && request.url().includes("type=theme"));
await page.locator(".market-preview-trigger").hover();
await themePreviewRequest;
await expect(page.locator("#stockPreview")).toBeVisible();
await expect(page.locator("#stockPreviewName")).toHaveText("人工智能");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
const intradayRequest = page.waitForRequest((request) => request.url().includes("/api/chart/intraday?") && request.url().includes("type=theme"));
await page.locator('[data-preview-chart="intraday"]').click();
const requestedIntraday = new URL((await intradayRequest).url());
expect(requestedIntraday.searchParams.get("id")).toBe("885728.TI");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
await page.locator("#closeStockPreview").click();
await page.locator("#themeSearch").fill("不存在的题材");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0);
@@ -1507,6 +1569,28 @@ test("stock hover preview ignores the selected historical date", async ({ page }
expect(canvasColors).toBeGreaterThan(4);
});
test("stock hover preview loading state follows the dark chart theme", async ({ page }) => {
await mockApplication(page, session("user", true), { previewDelay: 500 });
await page.goto("/index.html");
await page.evaluate(() => {
document.documentElement.dataset.theme = "dark";
showStockPreview("002141", document.querySelector("#globalSearchButton"));
});
const loading = page.locator("#stockPreviewLoading");
await expect(loading).toBeVisible();
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
const colors = await page.evaluate(() => ({
overlay: getComputedStyle(document.querySelector("#stockPreviewLoading")).backgroundColor,
chart: getComputedStyle(document.documentElement).getPropertyValue("--chart-background").trim(),
pixel: Array.from(
document.querySelector("#stockPreviewChart").getContext("2d").getImageData(10, 10, 1, 1).data,
),
}));
expect(colors.overlay).not.toBe("rgb(255, 255, 255)");
expect(colors.chart).toBe("#181b1e");
expect(colors.pixel.slice(0, 3)).toEqual([24, 27, 30]);
});
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");
@@ -1726,6 +1810,7 @@ test("mobile shell stays within the viewport", async ({ page }) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible();
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".topbar").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect();
@@ -1848,6 +1933,24 @@ test("new review workflows render account-scoped records", async ({ page }) => {
await page.locator("#reviewHistoryToggle").click();
await expect(page.locator("#reviewHistoryPanel")).toBeVisible();
await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true");
const reviewHistoryOverflow = await page.evaluate(() => {
const history = document.querySelector("#notesHistory");
const seed = history.querySelector(".note-row");
for (let index = 0; index < 18; index += 1) history.appendChild(seed.cloneNode(true));
const main = document.querySelector(".app-main");
return {
mainClientHeight: main.clientHeight,
mainScrollHeight: main.scrollHeight,
mainOverflowY: getComputedStyle(main).overflowY,
historyClientHeight: history.clientHeight,
historyScrollHeight: history.scrollHeight,
historyOverflowY: getComputedStyle(history).overflowY,
};
});
expect(reviewHistoryOverflow.mainOverflowY).toBe("auto");
expect(reviewHistoryOverflow.mainScrollHeight).toBeGreaterThan(reviewHistoryOverflow.mainClientHeight);
expect(reviewHistoryOverflow.historyOverflowY).toBe("auto");
expect(reviewHistoryOverflow.historyScrollHeight).toBeGreaterThan(reviewHistoryOverflow.historyClientHeight);
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
const tradeScroll = await page.evaluate(() => {
const seed = state.tradeEntries[0];
@@ -2447,15 +2550,27 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await page.locator("#themeToggle").click();
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small");
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
+187 -4
View File
@@ -12,18 +12,20 @@ from screener import (
FACTOR_GROUPS,
ScreenerEngine,
_broken_reversal_metrics,
_earnings_event_rows,
_popularity_factor_rows,
_risk_flags,
_rsi,
_quarter_periods,
)
from server import automatic_screener_jobs
from server import DashboardService, automatic_screener_jobs
class CuratedScreenerTests(unittest.TestCase):
def test_curated_library_contains_original_and_advanced_strategies(self):
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
self.assertEqual(23, len(CURATED_STRATEGIES))
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES}))
self.assertEqual(19, len(ADVANCED_CURATED_STRATEGIES))
self.assertEqual(29, len(CURATED_STRATEGIES))
self.assertEqual(29, len({item["name"] for item in CURATED_STRATEGIES}))
self.assertTrue(
{"行业动量轮动", "主力资金行业流入"}.issubset(
{item["name"] for item in CURATED_STRATEGIES}
@@ -32,6 +34,16 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue(
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
)
self.assertTrue(
{
"景气-趋势-拥挤三维行业打分",
"大小盘/成长价值风格切换(元策略)",
"业绩超预期漂移(SUE/PEAD)",
"多因子综合打分(IC动态加权)",
"热度突增潜伏(另类数据)",
"机构榜溢价",
}.issubset({item["name"] for item in CURATED_STRATEGIES})
)
def test_every_curated_strategy_explains_environment_and_failure_risk(self):
for strategy in CURATED_STRATEGIES:
@@ -85,6 +97,51 @@ class CuratedScreenerTests(unittest.TestCase):
}
self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"])
def test_server_gate_blocks_specialized_strategies_until_sources_are_ready(self):
factor_dates = [f"2026{index + 1:04d}" for index in range(260)]
health = {
"market": True,
"auction": True,
"benchmark": True,
"valuation": True,
"fundamental": True,
"dividend_history": True,
"moneyflow_history": True,
"earnings_events": False,
"popularity": False,
"institutions": False,
}
expected = {
"业绩超预期漂移(SUE/PEAD)": "业绩预告与快报",
"热度突增潜伏(另类数据)": "当日人气榜",
"机构榜溢价": "龙虎榜机构席位",
}
by_name = {strategy["name"]: strategy for strategy in CURATED_STRATEGIES}
for name, missing_label in expected.items():
self.assertEqual(
[missing_label],
DashboardService._strategy_missing_data(
by_name[name], factor_dates, health
),
name,
)
ready_health = {
**health,
"earnings_events": True,
"popularity": True,
"institutions": True,
}
for name in expected:
self.assertEqual(
[],
DashboardService._strategy_missing_data(
by_name[name], factor_dates, ready_health
),
name,
)
def test_factor_groups_cover_every_quant_factor(self):
grouped = [field for fields in FACTOR_GROUPS.values() for field in fields]
self.assertEqual(set(FACTOR_FIELDS), set(grouped))
@@ -109,6 +166,9 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
self.assertIn("fundamental_indicators", tables)
self.assertIn("benchmark_bars", tables)
self.assertIn("earnings_events", tables)
self.assertIn("popularity_factors", tables)
self.assertIn("lhb_institution_daily", tables)
def test_advanced_strategies_declare_history_and_backtest_contracts(self):
for strategy in ADVANCED_CURATED_STRATEGIES:
@@ -327,6 +387,129 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertLess(laggard["net_flow_5d_million"], 0)
self.assertEqual(leader["sector_flow_rank"], 1)
def test_stage_three_event_and_composite_factors_are_date_scoped(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
stocks = [
("600001.SH", "成长样本", "电子", 0.08),
("600002.SH", "价值样本", "银行", 0.02),
]
database.upsert_stock_master([
{
"ts_code": code, "name": name, "industry": industry,
"market": "主板", "list_date": "20000101",
}
for code, name, industry, _ in stocks
])
dates = []
cursor = datetime(2026, 3, 1)
while len(dates) < 80:
if cursor.weekday() < 5:
dates.append(cursor.strftime("%Y%m%d"))
cursor += timedelta(days=1)
database.upsert_daily_bars([
{
"trade_date": trade_date, "ts_code": code,
"open": 10 + index * slope - 0.02,
"high": 10 + index * slope + 0.08,
"low": 10 + index * slope - 0.08,
"close": 10 + index * slope,
"pct_chg": slope, "vol": 1000 + index, "amount": 300000,
}
for index, trade_date in enumerate(dates)
for code, _, _, slope in stocks
])
database.upsert_daily_indicators([
{
"trade_date": dates[-1], "ts_code": "600001.SH",
"turnover_rate": 3, "volume_ratio": 1.4, "total_mv": 900000,
"circ_mv": 700000, "pe_ttm": 25, "pb": 3, "ps_ttm": 4,
},
{
"trade_date": dates[-1], "ts_code": "600002.SH",
"turnover_rate": 1, "volume_ratio": 0.9, "total_mv": 5000000,
"circ_mv": 4000000, "pe_ttm": 8, "pb": 0.8, "ps_ttm": 1,
},
])
database.upsert_fundamental_indicators([
{
"end_date": "20260331", "ann_date": dates[-10],
"ts_code": "600001.SH", "roe": 16, "roic": 13,
"grossprofit_margin": 35, "netprofit_yoy": 45, "or_yoy": 30,
},
{
"end_date": "20260331", "ann_date": dates[-10],
"ts_code": "600002.SH", "roe": 9, "roic": 7,
"grossprofit_margin": 18, "netprofit_yoy": 5, "or_yoy": 3,
},
])
database.upsert_earnings_events([{
"end_date": "20260331", "ann_date": dates[-3],
"ts_code": "600001.SH", "forecast_profit": 100,
"actual_profit": 125, "surprise_pct": 25,
"revenue_yoy": 30, "netprofit_yoy": 45,
"source": "forecast+express",
}])
database.upsert_popularity_factors([{
"trade_date": dates[-1], "ts_code": "600001.SH",
"ths_rank": 5, "dc_rank": 8, "combined_score": 75,
"rank_change": 12, "dual_source": True,
}])
database.upsert_lhb_institutions([{
"trade_date": dates[-1], "ts_code": "600001.SH",
"exalter": "机构专用", "buy": 80_000_000,
"sell": 20_000_000, "net_buy": 60_000_000,
}])
factors, actual_date = ScreenerEngine(database).build_factors(
dates[-1], history_days=80
)
by_code = {item["ts_code"]: item for item in factors}
factor = by_code["600001.SH"]
self.assertEqual(actual_date, dates[-1])
self.assertEqual(factor["earnings_days_since_announce"], 2)
self.assertEqual(factor["earnings_surprise_pct"], 25)
self.assertEqual(factor["popularity_score"], 75)
self.assertEqual(factor["popularity_dual_source"], 1)
self.assertEqual(factor["institution_net_buy_million"], 60)
self.assertEqual(factor["institution_seat_count"], 1)
self.assertIsNotNone(factor["sector_composite_score"])
self.assertIsNotNone(factor["style_fit_score"])
self.assertIsNotNone(factor["multi_factor_composite"])
health = database.factor_health_summary(dates[-1])
self.assertTrue(health["earnings_events"])
self.assertTrue(health["popularity"])
self.assertTrue(health["institutions"])
def test_stage_three_sources_normalize_units_and_rank_changes(self):
earnings = _earnings_event_rows(
[{
"ts_code": "600001.SH", "ann_date": "20260401",
"end_date": "20260331", "net_profit_min": 10000,
"net_profit_max": 12000,
}],
[{
"ts_code": "600001.SH", "ann_date": "20260420",
"end_date": "20260331", "n_income": 132_000_000,
"yoy_net_profit": 30, "yoy_sales": 18,
}],
"20260420",
)
self.assertEqual(len(earnings), 1)
self.assertEqual(round(earnings[0]["actual_profit"]), 13200)
self.assertEqual(round(earnings[0]["surprise_pct"]), 20)
popularity = _popularity_factor_rows(
"20260420",
[{"data_type": "热股", "ts_code": "600001.SH", "rank": 5}],
[{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 8}],
[{"data_type": "热股", "ts_code": "600001.SH", "rank": 20}],
[{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 30}],
)
self.assertEqual(len(popularity), 1)
self.assertEqual(popularity[0]["rank_change"], 15)
self.assertTrue(popularity[0]["dual_source"])
def test_screen_reports_signal_health(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
+1 -1
View File
@@ -194,7 +194,7 @@ class FrontendContractTests(unittest.TestCase):
def test_stock_hover_preview_always_uses_latest_market_context(self):
start = self.script.index("async function showStockPreview")
end = self.script.index("function renderStockPreviewLoading", start)
end = self.script.index("async function showEntityPreview", start)
preview_loader = self.script[start:end]
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
self.assertIn('/preview`', preview_loader)
+48
View File
@@ -4,6 +4,7 @@ import tempfile
import unittest
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from chart_data_provider import EastmoneyChartClient, MarketChartClient
from database import ReviewDatabase
@@ -42,6 +43,45 @@ class FakeIfind:
return []
class FakeIfindStalePreopen(FakeIfind):
def history(self, codes, indicators, start_date, end_date, cache_ttl=0):
return [
*super().history(codes, indicators, start_date, end_date, cache_ttl),
{
"time": "2026-07-29",
"thscode": "000001.SZ",
"open": 10.5,
"high": 10.5,
"low": 10.5,
"close": 10.5,
"volume": 0,
"amount": 0,
},
]
def real_time(self, codes, indicators, cache_ttl=0):
return [
{
"time": "2026-07-28 15:00:00",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"latest": 10.5,
"preClose": 10.2,
"volume": 120,
"amount": 1_200_000,
}
]
class FixedPreopenDatetime(datetime):
fixed_now = datetime(2026, 7, 29, 8, 45, tzinfo=timezone(timedelta(hours=8)))
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class FakeIfindSnapshots:
configured = True
@@ -93,6 +133,14 @@ class IfindFeatureTests(unittest.TestCase):
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient())
with patch("chart_data_provider.datetime", FixedPreopenDatetime):
rows = client.stock_daily("000001", "20260729")
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertFalse(rows[-1].get("realtime", False))
def test_event_enrichment_keeps_blank_broken_reason_blank(self):
dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]}
DashboardService._merge_ifind_event_enrichment(
+42
View File
@@ -76,6 +76,10 @@ class FakeMarketClient:
return []
class ConfiguredIfind:
configured = True
class MarketInsightsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
@@ -170,6 +174,44 @@ class MarketInsightsTests(unittest.TestCase):
self.assertFalse(payload["meta"]["carried_forward"])
self.assertEqual(payload["rows"], [])
def test_finalized_window_uses_and_persists_ifind_closing_snapshot(self):
service = MarketInsightsService(
self.database,
FakeMarketClient(),
now_provider=lambda: datetime(
2026, 7, 24, 9, 31, tzinfo=timezone(timedelta(hours=8))
),
ifind=ConfiguredIfind(),
)
calls = []
service._dynamic_auction_rows = lambda trade_date, baseline_date, user_id: (
calls.append((trade_date, baseline_date, user_id))
or [
{
"ts_code": "000001.SZ",
"trade_date": trade_date,
"price": 10.5,
"pre_close": 10,
"vol": 20_000,
"amount": 5_000_000,
"turnover_rate": 0.12,
"volume_ratio": 1.8,
"dynamic": True,
}
]
)
payload = service.auction_center("20260724")
cached = service.auction_center("20260724")
self.assertEqual(payload["meta"]["phase"], "finalized")
self.assertTrue(payload["meta"]["available"])
self.assertEqual(payload["summary"]["stock_count"], 1)
self.assertEqual(payload["rows"][0]["code"], "000001")
self.assertEqual(len(calls), 1)
self.assertTrue(cached["meta"]["cached"])
self.assertEqual(cached["summary"]["stock_count"], 1)
def test_theme_library_detail_and_popularity(self):
library = self.service.theme_library("20260724")
self.assertEqual(library["meta"]["trade_date"], "2026-07-23")
+50 -1
View File
@@ -53,6 +53,14 @@ class FixedMarketDatetime(datetime):
return cls.fixed_now
class FixedPreopenDatetime(datetime):
fixed_now = datetime.now().astimezone().replace(hour=8, minute=45, 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)
@@ -110,7 +118,48 @@ class StockDetailRealtimeTests(unittest.TestCase):
result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2)
self.assertNotIn("realtime", result["meta"])
self.assertFalse(result["meta"].get("realtime", False))
self.assertEqual(RealtimeClientStub.quote_calls, 0)
def test_today_detail_keeps_last_traded_bar_before_market_open(self):
today = FixedPreopenDatetime.fixed_now.strftime("%Y%m%d")
today_display = FixedPreopenDatetime.fixed_now.strftime("%Y-%m-%d")
yesterday = (FixedPreopenDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d")
payload = {
"meta": {"trade_date": yesterday, "source": "tushare"},
"stock": {"code": "002141", "price": 10, "change": 0},
"prices": [
{
"trade_date": yesterday,
"open": 9.8,
"high": 10.1,
"low": 9.7,
"close": 10,
"change": 1.2,
"volume": 100,
},
{
"trade_date": today_display,
"open": 10,
"high": 10,
"low": 10,
"close": 10,
"change": 0,
"volume": 0,
"amount_billion": 0,
"realtime": True,
},
],
}
with patch("server.datetime", FixedPreopenDatetime), patch(
"server.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(result["meta"]["trade_date"], yesterday)
self.assertFalse(result["meta"].get("realtime", False))
self.assertEqual(result["prices"][-1]["trade_date"], yesterday)
self.assertEqual(result["stock"]["change"], 1.2)
self.assertEqual(RealtimeClientStub.quote_calls, 0)