diff --git a/next/frontend/src/app/views/EntityDetailView.vue b/next/frontend/src/app/views/EntityDetailView.vue
new file mode 100644
index 0000000..bf0d5f9
--- /dev/null
+++ b/next/frontend/src/app/views/EntityDetailView.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/next/frontend/src/main.ts b/next/frontend/src/main.ts
index d79efdb..8161aed 100644
--- a/next/frontend/src/main.ts
+++ b/next/frontend/src/main.ts
@@ -10,6 +10,7 @@ import "./shared/styles/components.css";
import "./shared/styles/shell.css";
import "./shared/styles/auth.css";
import "./shared/styles/account.css";
+import "./shared/styles/market.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
diff --git a/next/frontend/src/shared/account/SearchPanel.vue b/next/frontend/src/shared/account/SearchPanel.vue
index 4628670..b3274a1 100644
--- a/next/frontend/src/shared/account/SearchPanel.vue
+++ b/next/frontend/src/shared/account/SearchPanel.vue
@@ -1,19 +1,100 @@
-
+
-
-
- 股票板块题材指数
-
-
-
{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}
-
Ctrl+K
+
+
+
+
+
+
+
+
+
+ {{ group.label }}
+
+
+
+
+
diff --git a/next/frontend/src/shared/api/market.ts b/next/frontend/src/shared/api/market.ts
new file mode 100644
index 0000000..9e2b3a6
--- /dev/null
+++ b/next/frontend/src/shared/api/market.ts
@@ -0,0 +1,72 @@
+import { api } from "./client";
+
+export type TradeContext = {
+ requested_date: string;
+ actual_date: string | null;
+ previous_date: string | null;
+ observed_at: string | null;
+ state: "realtime" | "final" | "archive" | null;
+ carried_forward: boolean;
+ message: string;
+};
+
+export type MarketSummary = {
+ context: TradeContext;
+ values: Record
| null;
+};
+
+export type MarketEntity = {
+ entity_type: "stock" | "sector" | "theme" | "index";
+ identifier: string;
+ code: string;
+ name: string;
+ sector: string | null;
+};
+
+export type SearchGroup = {
+ entity_type: MarketEntity["entity_type"];
+ label: string;
+ items: MarketEntity[];
+};
+
+export type SearchResults = { query: string; groups: SearchGroup[] };
+
+export type ChartPoint = {
+ time: string;
+ open: number;
+ high: number;
+ low: number;
+ close: number;
+ volume: number;
+ amount: number;
+ average: number | null;
+};
+
+export type ChartSeries = {
+ entity_type: MarketEntity["entity_type"];
+ identifier: string;
+ code: string;
+ name: string;
+ interval: "day" | "minute";
+ trade_date: string;
+ observed_at: string;
+ previous_close: number | null;
+ range_start: string | null;
+ range_end: string | null;
+ points: ChartPoint[];
+};
+
+export const marketApi = {
+ summary(date?: string): Promise {
+ const query = date ? `?date=${encodeURIComponent(date)}` : "";
+ return api.get(`/market/summary${query}`);
+ },
+ search(query: string): Promise {
+ return api.get(`/market/search?q=${encodeURIComponent(query)}`);
+ },
+ chart(entity: MarketEntity, interval: "day" | "minute"): Promise {
+ const type = encodeURIComponent(entity.entity_type);
+ const identifier = encodeURIComponent(entity.identifier);
+ return api.get(`/market/entities/${type}/${identifier}/charts/${interval}`);
+ },
+};
diff --git a/next/frontend/src/shared/market/MarketChart.vue b/next/frontend/src/shared/market/MarketChart.vue
new file mode 100644
index 0000000..70d70ae
--- /dev/null
+++ b/next/frontend/src/shared/market/MarketChart.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
diff --git a/next/frontend/src/shared/market/MarketPreviewPanel.vue b/next/frontend/src/shared/market/MarketPreviewPanel.vue
new file mode 100644
index 0000000..40490f1
--- /dev/null
+++ b/next/frontend/src/shared/market/MarketPreviewPanel.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ 正在读取真实行情
+ {{ error }}
+
+ 选择一个结果查看最新行情
+
+
+
diff --git a/next/frontend/src/shared/stores/market.ts b/next/frontend/src/shared/stores/market.ts
index 0d54eb1..6abc50f 100644
--- a/next/frontend/src/shared/stores/market.ts
+++ b/next/frontend/src/shared/stores/market.ts
@@ -1,6 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
+import { marketApi, type MarketSummary } from "../api/market";
+
function shanghaiDate(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
@@ -12,12 +14,32 @@ function shanghaiDate(): string {
export const useMarketStore = defineStore("market", () => {
const selectedDate = ref(shanghaiDate());
+ const summary = ref(null);
+ const loading = ref(false);
+ const error = ref("");
- function moveDate(offset: number): void {
- const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
- date.setUTCDate(date.getUTCDate() + offset);
- selectedDate.value = date.toISOString().slice(0, 10);
+ async function load(date?: string): Promise {
+ loading.value = true;
+ error.value = "";
+ try {
+ summary.value = await marketApi.summary(date);
+ selectedDate.value = summary.value.context.actual_date ?? summary.value.context.requested_date;
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : "行情摘要读取失败";
+ } finally {
+ loading.value = false;
+ }
}
- return { selectedDate, moveDate };
+ async function selectDate(date: string): Promise {
+ await load(date);
+ }
+
+ async function moveDate(offset: number): Promise {
+ const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
+ date.setUTCDate(date.getUTCDate() + offset);
+ await load(date.toISOString().slice(0, 10));
+ }
+
+ return { selectedDate, summary, loading, error, load, selectDate, moveDate };
});
diff --git a/next/frontend/src/shared/styles/account.css b/next/frontend/src/shared/styles/account.css
index 03eba08..5623140 100644
--- a/next/frontend/src/shared/styles/account.css
+++ b/next/frontend/src/shared/styles/account.css
@@ -103,21 +103,6 @@
min-height: var(--s-44);
}
-.search-categories {
- display: flex;
- gap: var(--s-8);
- padding-bottom: var(--s-10);
- border-bottom: var(--s-1) solid var(--color-divider);
-}
-
-.search-categories span {
- padding: var(--s-4) var(--s-9);
- border-radius: var(--tag-radius);
- color: var(--color-text-secondary);
- background: var(--color-surface-muted);
- font-size: var(--font-11);
-}
-
.search-empty {
min-height: var(--s-200);
display: grid;
@@ -128,6 +113,67 @@
font-size: var(--font-12);
}
+.search-layout {
+ min-height: var(--s-320);
+ display: grid;
+ grid-template-columns: var(--s-320) minmax(0, 1fr);
+ gap: var(--s-12);
+}
+
+.search-results {
+ max-height: var(--s-400);
+ overflow-y: auto;
+ border-right: var(--s-1) solid var(--color-divider);
+ padding-right: var(--s-12);
+}
+
+.search-group + .search-group {
+ margin-top: var(--s-12);
+}
+
+.search-group h3 {
+ padding: var(--s-4) var(--s-8);
+ color: var(--color-text-faint);
+ font-size: var(--font-11);
+ font-weight: var(--weight-600);
+}
+
+.search-result {
+ width: 100%;
+ min-height: var(--s-36);
+ display: grid;
+ grid-template-columns: var(--s-64) minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--s-8);
+ padding: var(--s-6) var(--s-8);
+ border-radius: var(--control-radius);
+ color: var(--color-text-secondary);
+ background: var(--c-transparent);
+ text-align: left;
+}
+
+.search-result:hover,
+.search-result.active {
+ color: var(--color-text);
+ background: var(--color-primary-soft);
+}
+
+.search-result strong {
+ overflow: hidden;
+ color: var(--color-text);
+ font-size: var(--font-12-5);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.search-result span {
+ font-size: var(--font-11);
+}
+
+.result-code {
+ font-variant-numeric: tabular-nums;
+}
+
.search-empty span {
padding: var(--s-2) var(--s-6);
border: var(--s-1) solid var(--color-border);
diff --git a/next/frontend/src/shared/styles/market.css b/next/frontend/src/shared/styles/market.css
new file mode 100644
index 0000000..90d2354
--- /dev/null
+++ b/next/frontend/src/shared/styles/market.css
@@ -0,0 +1,126 @@
+.market-preview {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: auto minmax(var(--s-260), 1fr) auto;
+ overflow: hidden;
+ background: var(--color-surface);
+}
+
+.preview-header {
+ min-height: var(--s-40);
+ display: flex;
+ align-items: center;
+ gap: var(--s-12);
+ padding: var(--s-8) var(--s-12);
+ border-bottom: var(--s-1) solid var(--color-divider);
+}
+
+.preview-header > div:first-child {
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ gap: var(--s-8);
+}
+
+.preview-header strong {
+ font-size: var(--font-14);
+}
+
+.preview-header span,
+.preview-meta {
+ color: var(--color-text-faint);
+ font-size: var(--font-11);
+}
+
+.seg-control {
+ display: inline-flex;
+ gap: var(--s-2);
+ margin-left: auto;
+ padding: var(--s-2);
+ border-radius: var(--control-radius);
+ background: var(--color-surface-muted);
+}
+
+.seg-control button {
+ min-height: var(--s-24);
+ padding: var(--s-4) var(--s-8);
+ border-radius: var(--radius-5);
+ color: var(--color-text-secondary);
+ background: var(--c-transparent);
+ font-size: var(--font-11);
+}
+
+.seg-control button.active {
+ color: var(--color-primary);
+ background: var(--color-surface);
+}
+
+.market-chart {
+ width: 100%;
+ height: 100%;
+ min-height: var(--s-260);
+ padding: var(--s-8);
+ background: var(--color-surface);
+}
+
+.candle-up,
+.candle-down {
+ stroke-width: var(--s-1);
+}
+
+.candle-up {
+ fill: var(--color-surface);
+ stroke: var(--color-up);
+}
+
+.candle-down {
+ fill: var(--color-down);
+ stroke: var(--color-down);
+}
+
+.chart-price,
+.chart-average {
+ fill: none;
+ stroke-width: var(--s-2);
+}
+
+.chart-price {
+ stroke: var(--color-primary);
+}
+
+.chart-average {
+ stroke: var(--color-warning);
+}
+
+.chart-zero {
+ stroke: var(--color-text-faint);
+ stroke-width: var(--s-1);
+ stroke-dasharray: var(--s-4) var(--s-4);
+}
+
+.preview-state {
+ min-height: var(--s-260);
+ display: grid;
+ place-items: center;
+ color: var(--color-text-secondary);
+ font-size: var(--font-12);
+}
+
+.preview-meta {
+ min-height: var(--s-30);
+ padding: var(--s-7) var(--s-12);
+ border-top: var(--s-1) solid var(--color-divider);
+}
+
+.entity-heading {
+ justify-content: space-between;
+ align-items: center;
+}
+
+.entity-heading h1 {
+ display: inline;
+}
+
+.entity-chart {
+ min-height: var(--s-400);
+}
diff --git a/next/frontend/src/shared/styles/mobile.css b/next/frontend/src/shared/styles/mobile.css
index 4bf7057..16a64f0 100644
--- a/next/frontend/src/shared/styles/mobile.css
+++ b/next/frontend/src/shared/styles/mobile.css
@@ -114,6 +114,20 @@
grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64);
}
+ .search-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .search-results {
+ max-height: none;
+ border-right: 0;
+ padding-right: 0;
+ }
+
+ .search-preview {
+ display: none;
+ }
+
.auth-view {
align-items: start;
padding-top: var(--s-34);
diff --git a/next/tests/e2e/stage5.spec.js b/next/tests/e2e/stage5.spec.js
new file mode 100644
index 0000000..d28fb14
--- /dev/null
+++ b/next/tests/e2e/stage5.spec.js
@@ -0,0 +1,118 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+const { expect, test } = require("@playwright/test");
+
+const evidence = path.resolve(__dirname, "../../docs/evidence/stage-5");
+
+test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
+
+async function authenticate(page) {
+ await page.goto("/");
+ await page.getByLabel("账号名").fill("stage5admin");
+ await page.getByLabel("密码").fill("Stage5-pass-123!");
+ await page.getByRole("button", { name: "登录", exact: true }).click();
+ await expect(page.locator(".sidebar, .field-error")).toBeVisible();
+ if (!(await page.locator(".sidebar").isVisible())) {
+ await page.getByRole("tab", { name: "注册" }).click();
+ await page.getByRole("button", { name: "注册并登录" }).click();
+ }
+}
+
+function chartPayload(interval) {
+ const daily = [
+ ["2026-07-23", 3500, 3540, 3480, 3530],
+ ["2026-07-24", 3530, 3568, 3510, 3552],
+ ["2026-07-25", 3550, 3580, 3524, 3540],
+ ["2026-07-28", 3542, 3590, 3530, 3582],
+ ["2026-07-29", 3585, 3612, 3570, 3604],
+ ];
+ const minute = [
+ ["09:30", 3600, 3608, 3594, 3604, 3602],
+ ["10:30", 3604, 3616, 3600, 3610, 3607],
+ ["11:30", 3610, 3614, 3602, 3606, 3608],
+ ["14:00", 3606, 3620, 3604, 3618, 3610],
+ ["15:00", 3618, 3622, 3608, 3612, 3613],
+ ];
+ return {
+ entity_type: "index",
+ identifier: "000001.SH",
+ code: "000001",
+ name: "上证指数",
+ interval,
+ trade_date: "2026-07-29",
+ observed_at: "2026-07-29T15:00:00+08:00",
+ previous_close: 3594,
+ range_start: interval === "minute" ? "09:30" : null,
+ range_end: interval === "minute" ? "15:00" : null,
+ points: (interval === "day" ? daily : minute).map((row) => ({
+ time: row[0],
+ open: row[1],
+ high: row[2],
+ low: row[3],
+ close: row[4],
+ volume: 100000,
+ amount: 360000000,
+ average: interval === "minute" ? row[5] : null,
+ })),
+ };
+}
+
+test("latest snapshot, grouped search, chart preview and entity detail", async ({ page }) => {
+ const consoleErrors = [];
+ page.on("console", (message) => {
+ if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) {
+ consoleErrors.push(message.text());
+ }
+ });
+ await page.route("**/api/market/summary", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ context: {
+ requested_date: "2026-07-30",
+ actual_date: "2026-07-29",
+ previous_date: "2026-07-28",
+ observed_at: "2026-07-29T15:00:00+08:00",
+ state: "final",
+ carried_forward: true,
+ message: "沿用最近真实收盘快照",
+ },
+ values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
+ }),
+ }),
+ );
+ await page.route("**/api/market/entities/index/000001.SH/charts/*", (route) => {
+ const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
+ return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) });
+ });
+
+ await authenticate(page);
+ await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
+ await expect(page.locator(".market-strip-row")).toContainText("07/29 15:00");
+
+ await page.keyboard.press("Control+K");
+ const dialog = page.getByRole("dialog", { name: "全局搜索" });
+ await dialog.getByPlaceholder("搜索股票、板块、题材或指数").fill("上证");
+ await expect(dialog.getByRole("button", { name: /上证指数/ })).toBeVisible();
+ await expect(dialog.locator(".market-chart")).toBeVisible();
+ await expect(dialog).toContainText("数据日期 2026-07-29");
+ await page.screenshot({ path: path.join(evidence, "search-preview-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
+
+ await dialog.getByRole("button", { name: /上证指数/ }).click();
+ await expect(page).toHaveURL(/\/market\/index\/000001.SH/);
+ await expect(page.getByRole("heading", { name: "上证指数" })).toBeVisible();
+ await page.getByRole("button", { name: "分时" }).click();
+ await expect(page.locator(".chart-zero")).toHaveCount(1);
+ await expect(page.locator(".preview-meta")).toContainText("09:30–15:00");
+
+ await page.getByRole("button", { name: "夜间" }).click();
+ await page.screenshot({ path: path.join(evidence, "entity-detail-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.locator(".market-chart")).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
+ await page.screenshot({ path: path.join(evidence, "entity-detail-dark-390x844.jpg"), type: "jpeg", quality: 82 });
+ expect(consoleErrors).toEqual([]);
+});
diff --git a/next/tests/test_market_data.py b/next/tests/test_market_data.py
new file mode 100644
index 0000000..215738f
--- /dev/null
+++ b/next/tests/test_market_data.py
@@ -0,0 +1,258 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import httpx
+import pytest
+
+from backend.bootstrap.application import create_application
+from backend.bootstrap.settings import Settings
+from backend.data.contracts import (
+ DataSource,
+ DataUsage,
+ ObservationMetadata,
+ ProviderResult,
+ SnapshotState,
+)
+from backend.data.gateway import DataGateway
+from backend.data.policy import DataPolicyError, DataSourcePolicy
+from backend.data.providers.ifind import IfindProvider
+from backend.data.repository import MarketRepository
+from backend.database.connection import Database
+from backend.database.migrations import MIGRATIONS, MigrationRunner
+from tests.support import run_scenario
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+
+
+class FakeProvider:
+ source = DataSource.IFIND
+ configured = True
+
+ def calendar(self, start_date: str, end_date: str) -> ProviderResult:
+ raise AssertionError("not used")
+
+ def entities(self) -> ProviderResult:
+ raise AssertionError("not used")
+
+ def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
+ return ProviderResult(
+ (
+ {
+ "time": "2026-07-28 15:00:00",
+ "open": 10,
+ "high": 11,
+ "low": 9.8,
+ "close": 10.8,
+ "volume": 1000,
+ "amount": 10800,
+ },
+ {
+ "time": "2026-07-29 15:00:00",
+ "open": 11,
+ "high": 11.2,
+ "low": 10.7,
+ "close": 11.1,
+ "volume": 1200,
+ "amount": 13320,
+ },
+ {
+ "time": "2026-07-30 09:15:00",
+ "open": 0,
+ "high": 0,
+ "low": 0,
+ "close": 11.1,
+ "volume": 0,
+ "amount": 0,
+ },
+ ),
+ metadata(DataSource.IFIND, SnapshotState.ARCHIVE),
+ )
+
+ def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
+ rows = (
+ {
+ "time": f"{trade_date} 09:30:00",
+ "open": 11,
+ "high": 11.1,
+ "low": 10.9,
+ "close": 11.05,
+ "volume": 100,
+ "amount": 1105,
+ "avgPrice": 11.03,
+ "preClose": 11,
+ },
+ )
+ return ProviderResult(rows, metadata(DataSource.IFIND, SnapshotState.REALTIME))
+
+
+def metadata(source: DataSource, state: SnapshotState) -> ObservationMetadata:
+ return ObservationMetadata(
+ source=source,
+ observed_at=datetime(2026, 7, 30, 9, 15, tzinfo=SHANGHAI),
+ unit="yuan/share",
+ adjustment="forward",
+ freshness_seconds=0,
+ coverage=1,
+ state=state,
+ usage=DataUsage.DISPLAY,
+ )
+
+
+def gateway(tmp_path) -> DataGateway:
+ database = Database(tmp_path / "market.db")
+ MigrationRunner(database).upgrade(MIGRATIONS)
+ repository = MarketRepository()
+ with database.transaction() as connection:
+ repository.replace_stocks(
+ connection,
+ (
+ {
+ "ts_code": "000001.SZ",
+ "symbol": "000001",
+ "name": "平安银行",
+ "industry": "银行",
+ "list_status": "L",
+ },
+ ),
+ "tushare",
+ "2026-07-29T15:00:00+08:00",
+ )
+ connection.execute(
+ """
+ INSERT INTO market_summaries
+ (trade_date, observed_at, state, source, coverage, payload_json, created_at)
+ VALUES (?, ?, 'final', 'tushare', 1, ?, ?)
+ """,
+ (
+ "2026-07-29",
+ "2026-07-29T15:00:00+08:00",
+ json.dumps({"limit_up": 46, "limit_down": 3}),
+ "2026-07-29T15:05:00+08:00",
+ ),
+ )
+ return DataGateway(database, repository, (FakeProvider(),), DataSourcePolicy())
+
+
+def test_public_sources_cannot_enter_calculations() -> None:
+ policy = DataSourcePolicy()
+ with pytest.raises(DataPolicyError):
+ policy.assert_allowed(DataSource.EASTMONEY, DataUsage.CALCULATION)
+ policy.assert_allowed(DataSource.EASTMONEY, DataUsage.DISPLAY)
+
+
+def test_ifind_top_level_tables_and_expired_access_token_are_handled() -> None:
+ provider = IfindProvider("refresh-token", "expired-token")
+ calls: list[tuple[str, str]] = []
+
+ def post(endpoint, body, access, refresh=""):
+ calls.append((endpoint, access or refresh))
+ if endpoint == "get_access_token":
+ return {"errorcode": 0, "data": {"access_token": "fresh-token"}}
+ if access == "expired-token":
+ return {"errorcode": -1302, "errmsg": "token expired"}
+ return {
+ "errorcode": 0,
+ "tables": [
+ {
+ "thscode": ["000001.SZ"],
+ "time": ["2026-07-29 15:00:00"],
+ "table": {
+ "open": [10],
+ "high": [11],
+ "low": [9],
+ "close": [10.5],
+ "volume": [100],
+ "amount": [1050],
+ },
+ }
+ ],
+ }
+
+ provider._post = post
+ result = provider.daily("stock", "000001.SZ", "2026-07-29")
+ assert result.rows[0]["time"] == "2026-07-29 15:00:00"
+ assert result.rows[0]["thscode"] == "000001.SZ"
+ assert calls == [
+ ("cmd_history_quotation", "expired-token"),
+ ("get_access_token", "refresh-token"),
+ ("cmd_history_quotation", "fresh-token"),
+ ]
+
+
+def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
+ market = gateway(tmp_path)
+ context = market.trade_context(
+ "2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
+ )
+ assert context.requested_date == "2026-07-30"
+ assert context.actual_date == "2026-07-29"
+ assert context.carried_forward is True
+ assert context.observed_at.isoformat() == "2026-07-29T15:00:00+08:00"
+
+
+def test_latest_daily_chart_drops_empty_premarket_bar(tmp_path) -> None:
+ series = gateway(tmp_path).chart(
+ "stock", "000001.SZ", "day", datetime(2026, 7, 30, 9, 15, tzinfo=SHANGHAI)
+ )
+ assert series.trade_date == "2026-07-29"
+ assert [point.time for point in series.points] == ["2026-07-28", "2026-07-29"]
+ assert series.points[-1].amount == 13320
+
+
+def test_minute_chart_contract_has_real_session_bounds_and_hides_source(tmp_path) -> None:
+ application = create_application(Settings.for_test(tmp_path))
+
+ async def scenario(client: httpx.AsyncClient) -> None:
+ registered = await client.post(
+ "/api/auth/register",
+ json={"username": "market-user", "password": "Market-pass-123!"},
+ )
+ assert registered.status_code == 201
+ fake_market = type(
+ "FakeMarketService",
+ (),
+ {
+ "chart": lambda self, *_: {
+ "entity_type": "stock",
+ "identifier": "000001.SZ",
+ "code": "000001",
+ "name": "平安银行",
+ "interval": "minute",
+ "trade_date": "2026-07-29",
+ "observed_at": "2026-07-29T15:00:00+08:00",
+ "previous_close": 10.9,
+ "range_start": "09:30",
+ "range_end": "15:00",
+ "points": [],
+ }
+ },
+ )()
+ object.__setattr__(application.state.container, "market", fake_market)
+ response = await client.get("/api/market/entities/stock/000001.SZ/charts/minute")
+ assert response.status_code == 200
+ assert response.json()["range_start"] == "09:30"
+ assert response.json()["range_end"] == "15:00"
+ assert "source" not in response.text
+
+ run_scenario(application, scenario)
+
+
+def test_search_groups_are_fixed_and_require_authentication(tmp_path) -> None:
+ application = create_application(Settings.for_test(tmp_path))
+
+ async def scenario(client: httpx.AsyncClient) -> None:
+ assert (await client.get("/api/market/search?q=上证")).status_code == 401
+ await client.post(
+ "/api/auth/register",
+ json={"username": "search-user", "password": "Search-pass-123!"},
+ )
+ response = await client.get("/api/market/search?q=上证")
+ assert response.status_code == 200
+ groups = response.json()["groups"]
+ assert [group["label"] for group in groups] == ["股票", "板块", "题材", "指数"]
+ assert groups[-1]["items"][0]["name"] == "上证指数"
+
+ run_scenario(application, scenario)
diff --git a/next/tests/test_migrations.py b/next/tests/test_migrations.py
index 5bc57e8..f493c01 100644
--- a/next/tests/test_migrations.py
+++ b/next/tests/test_migrations.py
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
- assert runner.upgrade(MIGRATIONS) == (1, 2)
+ assert runner.upgrade(MIGRATIONS) == (1, 2, 3)
assert {
"users",
"memberships",
@@ -120,8 +120,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"system_credentials",
"llm_models",
"llm_configuration",
+ "trading_days",
+ "market_entities",
+ "market_summaries",
+ "chart_series",
} <= table_names(database)
- assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1)
+ assert runner.downgrade(MIGRATIONS, target_version=0) == (3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)