rebuild(stage-6): deliver emotion and market pools
This commit is contained in:
@@ -19,9 +19,13 @@ from backend.data.contracts import (
|
||||
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")
|
||||
@@ -256,3 +260,149 @@ def test_search_groups_are_fixed_and_require_authentication(tmp_path) -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user