rebuild(stage-5): establish market data gateway and charts
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user