feat: complete strategy and market data improvements
This commit is contained in:
+136
-21
@@ -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 }) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user