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)