409 lines
14 KiB
Python
409 lines
14 KiB
Python
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.providers.tushare import TushareProvider
|
|
from backend.data.repository import MarketRepository
|
|
from backend.database.connection import Database
|
|
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
|
from backend.features.market.sentiment import calculate_sentiment
|
|
from backend.features.market.snapshot import build_snapshot
|
|
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
|
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)
|
|
|
|
|
|
def calculation_result(rows) -> ProviderResult:
|
|
return ProviderResult(
|
|
tuple(rows),
|
|
ObservationMetadata(
|
|
source=DataSource.TUSHARE,
|
|
observed_at=datetime(2026, 7, 29, 15, tzinfo=SHANGHAI),
|
|
unit="mixed",
|
|
adjustment="not_applicable",
|
|
freshness_seconds=0,
|
|
coverage=1,
|
|
state=SnapshotState.FINAL,
|
|
usage=DataUsage.CALCULATION,
|
|
),
|
|
)
|
|
|
|
|
|
def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> None:
|
|
daily = [
|
|
{
|
|
"ts_code": f"00000{index}.SZ",
|
|
"close": 10 + index,
|
|
"pct_chg": change,
|
|
"amount": 100,
|
|
}
|
|
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
|
|
]
|
|
def event(index, streak=1):
|
|
return {
|
|
"ts_code": f"00000{index}.SZ",
|
|
"name": f"样本{index}",
|
|
"industry": "测试行业",
|
|
"close": 10 + index,
|
|
"pct_chg": daily[index - 1]["pct_chg"],
|
|
"amount": 100,
|
|
"limit_times": streak,
|
|
}
|
|
snapshot = build_snapshot(
|
|
"2026-07-29",
|
|
"2026-07-28",
|
|
{
|
|
"daily": calculation_result(daily),
|
|
"limit_up": calculation_result([event(1, 2)]),
|
|
"broken": calculation_result([event(2)]),
|
|
"limit_down": calculation_result([event(3)]),
|
|
"previous_limit_up": calculation_result([event(index) for index in range(1, 6)]),
|
|
"price_limits": calculation_result(
|
|
[{"ts_code": "000002.SZ", "up_limit": 15, "down_limit": 9}]
|
|
),
|
|
},
|
|
)
|
|
assert snapshot["overview"]["amount"] == 500_000
|
|
assert snapshot["broken"][0]["distance_to_limit"] == 20
|
|
assert [row["outcome"] for row in snapshot["yesterday_limits"]] == [
|
|
"晋级",
|
|
"炸板",
|
|
"跌停",
|
|
"红盘",
|
|
"断板",
|
|
]
|
|
performance = snapshot["limit_performance"][0]
|
|
assert performance == {
|
|
"level": 1,
|
|
"count": 5,
|
|
"advanced": 1,
|
|
"red": 1,
|
|
"broken": 1,
|
|
"opened": 1,
|
|
"limit_down": 1,
|
|
"advance_rate": 20.0,
|
|
"positive_rate": 60.0,
|
|
"average_change": 0.8,
|
|
}
|
|
|
|
|
|
def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
|
|
snapshot = {
|
|
"overview": {
|
|
"up_count": 10,
|
|
"down_count": 90,
|
|
"limit_up": 10,
|
|
"limit_down": 100,
|
|
"broken": 20,
|
|
"seal_rate": 33.3,
|
|
"amount": 100_000_000_000,
|
|
},
|
|
"limits": [{"streak": 1, "amount": 100_000_000} for _ in range(10)],
|
|
"yesterday_limits": [],
|
|
}
|
|
sentiment = calculate_sentiment(snapshot, [])
|
|
assert sentiment["score"] <= 15
|
|
assert sentiment["phase"] == "冰点"
|
|
assert {item["key"]: item["weight"] for item in sentiment["components"]} == {
|
|
"breadth": 20,
|
|
"limit_ecology": 25,
|
|
"profit_effect": 30,
|
|
"ladder_structure": 15,
|
|
"liquidity": 10,
|
|
}
|
|
|
|
|
|
def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) -> None:
|
|
database = Database(tmp_path / "sync.db")
|
|
MigrationRunner(database).upgrade(MIGRATIONS)
|
|
repository = MarketRepository()
|
|
with database.transaction() as connection:
|
|
repository.replace_calendar(
|
|
connection,
|
|
(
|
|
{"cal_date": "20260728", "is_open": 1, "pretrade_date": "20260727"},
|
|
{"cal_date": "20260729", "is_open": 1, "pretrade_date": "20260728"},
|
|
),
|
|
"tushare",
|
|
"2026-07-29T15:00:00+08:00",
|
|
)
|
|
repository.replace_stocks(
|
|
connection,
|
|
tuple(
|
|
{
|
|
"ts_code": f"{index:06d}.SZ",
|
|
"symbol": f"{index:06d}",
|
|
"name": f"样本{index}",
|
|
"industry": "测试",
|
|
"list_status": "L",
|
|
}
|
|
for index in range(1, 101)
|
|
),
|
|
"tushare",
|
|
"2026-07-29T15:00:00+08:00",
|
|
)
|
|
|
|
provider = TushareProvider("test-token")
|
|
provider.snapshot_inputs = lambda *_: {
|
|
"daily": calculation_result([{"ts_code": "000001.SZ"}]),
|
|
"limit_up": calculation_result([]),
|
|
"limit_down": calculation_result([]),
|
|
"broken": calculation_result([]),
|
|
"previous_limit_up": calculation_result([]),
|
|
"price_limits": calculation_result([]),
|
|
}
|
|
service = MarketSnapshotService(database, repository, provider)
|
|
with pytest.raises(SnapshotSyncError, match="覆盖率"):
|
|
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
|
|
with database.read() as connection:
|
|
assert repository.latest_summary(connection, "2026-07-29") is None
|