rebuild(stage-5): establish market data gateway and charts

This commit is contained in:
leefer
2026-07-30 02:35:42 +08:00
parent 40ad5d6836
commit cf0ab7026f
45 changed files with 2701 additions and 46 deletions
+258
View File
@@ -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)