rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from backend.data.contracts import (
|
||||
DataSource,
|
||||
DataUsage,
|
||||
ObservationMetadata,
|
||||
ProviderResult,
|
||||
SnapshotState,
|
||||
)
|
||||
from backend.data.gateway import MarketDataUnavailable
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.market.insights.auction import build_auction, build_watchlist_rows
|
||||
from backend.features.market.insights.dragon import build_dragon_list
|
||||
from backend.features.market.insights.popularity import build_popularity
|
||||
from backend.features.market.insights.service import MarketInsightService
|
||||
from backend.features.market.insights.themes import build_theme_detail, build_theme_library
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def test_auction_keeps_market_cores_and_isolates_real_limit_price() -> None:
|
||||
directory = {
|
||||
f"000{index:03d}.SZ": {
|
||||
"identifier": f"000{index:03d}.SZ",
|
||||
"code": f"000{index:03d}",
|
||||
"name": "ST样本" if index == 1 else f"样本{index}",
|
||||
"sector": "机器人",
|
||||
}
|
||||
for index in range(1, 36)
|
||||
}
|
||||
prior_limits = [
|
||||
{
|
||||
"identifier": identifier,
|
||||
"code": stock["code"],
|
||||
"name": stock["name"],
|
||||
"sector": "机器人",
|
||||
"streak": 3,
|
||||
"amount": index * 10_000_000,
|
||||
}
|
||||
for index, (identifier, stock) in enumerate(directory.items(), start=1)
|
||||
]
|
||||
rows = tuple(
|
||||
{
|
||||
"ts_code": identifier,
|
||||
"price": 11 if index == 1 else 10.5,
|
||||
"pre_close": 10,
|
||||
"amount": 5_000_000,
|
||||
"vol": 500_000,
|
||||
"turnover_rate": 0.2,
|
||||
"volume_ratio": 1.5,
|
||||
}
|
||||
for index, identifier in enumerate(directory, start=1)
|
||||
)
|
||||
result = build_auction(
|
||||
trade_date="2026-07-30",
|
||||
raw_rows=rows,
|
||||
price_limits=({"ts_code": "000001.SZ", "up_limit": 11},),
|
||||
directory=directory,
|
||||
prior_snapshot={
|
||||
"limits": prior_limits,
|
||||
"broken": [],
|
||||
"sectors": [{"name": "机器人", "count": 35}],
|
||||
},
|
||||
ths_hot=(),
|
||||
dc_hot=(),
|
||||
history=[
|
||||
{"trade_date": "2026-07-29", "amount_billion": 1.2, "stock_count": 35}
|
||||
],
|
||||
dynamic=False,
|
||||
)
|
||||
|
||||
assert result["summary"]["one_price_count"] == 1
|
||||
assert result["one_price_rows"][0]["code"] == "000001"
|
||||
assert result["one_price_rows"][0]["is_market_core"] is True
|
||||
assert len(result["focus_rows"]) == 34
|
||||
assert result["summary"]["amount_billion"] == result["amount_history"][-1]["amount_billion"]
|
||||
|
||||
|
||||
def test_watchlist_rows_are_built_only_from_current_account_entries() -> None:
|
||||
market = [
|
||||
{
|
||||
"identifier": "000001.SZ",
|
||||
"code": "000001",
|
||||
"name": "平安银行",
|
||||
"sector": "银行",
|
||||
"change": 1.5,
|
||||
"amount_million": 10,
|
||||
"volume_ratio": 1.2,
|
||||
}
|
||||
]
|
||||
rows = build_watchlist_rows(
|
||||
market,
|
||||
[],
|
||||
[],
|
||||
({"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},),
|
||||
)
|
||||
|
||||
assert [row["identifier"] for row in rows] == ["000001.SZ"]
|
||||
assert rows[0]["is_watchlist"] is True
|
||||
assert rows[0]["available"] is True
|
||||
|
||||
|
||||
def test_watchlist_repository_isolates_accounts(tmp_path) -> None:
|
||||
database = Database(tmp_path / "watchlists.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = MarketRepository()
|
||||
with database.transaction() as connection:
|
||||
for user_id, username in ((1, "account-a"), (2, "account-b")):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, username_key, password_hash, is_admin,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'hash', 0, 'active', '2026-07-30', '2026-07-30')
|
||||
""",
|
||||
(user_id, username, username),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO watchlist_entries (
|
||||
user_id, identifier, name, sector, created_at
|
||||
) VALUES (?, ?, ?, ?, '2026-07-30')
|
||||
""",
|
||||
(
|
||||
(1, "000001.SZ", "平安银行", "银行"),
|
||||
(2, "000002.SZ", "万科A", "房地产"),
|
||||
),
|
||||
)
|
||||
|
||||
with database.read() as connection:
|
||||
first = repository.watchlist(connection, 1)
|
||||
second = repository.watchlist(connection, 2)
|
||||
|
||||
assert [row["identifier"] for row in first] == ["000001.SZ"]
|
||||
assert [row["identifier"] for row in second] == ["000002.SZ"]
|
||||
|
||||
|
||||
def test_popularity_preserves_single_source_without_false_consensus() -> None:
|
||||
ths = (
|
||||
{
|
||||
"data_type": "热股",
|
||||
"rank": 1,
|
||||
"ts_code": "000001.SZ",
|
||||
"ts_name": "平安银行",
|
||||
"pct_change": 2,
|
||||
"concept": '["银行"]',
|
||||
},
|
||||
)
|
||||
result = build_popularity("2026-07-30", ths, (), (), ())
|
||||
|
||||
assert result["summary"] == {"ths_count": 1, "dc_count": 0, "dual_count": 0}
|
||||
assert result["combined"][0]["dual_source"] is False
|
||||
assert result["combined"][0]["concepts"] == ["银行"]
|
||||
|
||||
|
||||
def test_theme_library_and_detail_keep_quote_and_member_empty_states_separate() -> None:
|
||||
library = build_theme_library(
|
||||
"2026-07-30",
|
||||
(
|
||||
{
|
||||
"ts_code": "885001.TI",
|
||||
"name": "机器人",
|
||||
"count": 2,
|
||||
"exchange": "A",
|
||||
"type": "N",
|
||||
},
|
||||
),
|
||||
(),
|
||||
(),
|
||||
)
|
||||
assert library["summary"]["theme_count"] == 1
|
||||
assert library["summary"]["quoted_count"] == 0
|
||||
|
||||
detail = build_theme_detail(
|
||||
"2026-07-30",
|
||||
library["items"][0],
|
||||
({"con_code": "000001.SZ", "con_name": "平安银行"},),
|
||||
(),
|
||||
)
|
||||
assert detail["summary"]["member_count"] == 1
|
||||
assert detail["summary"]["quoted_count"] == 0
|
||||
assert detail["members"][0]["quoted"] is False
|
||||
|
||||
|
||||
def test_dragon_list_distinguishes_missing_and_unclassified_seats() -> None:
|
||||
stocks = (
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"name": "平安银行",
|
||||
"pct_change": 3.2,
|
||||
"reason": "日涨幅偏离值达7%",
|
||||
},
|
||||
)
|
||||
missing = build_dragon_list(
|
||||
trade_date="2026-07-30",
|
||||
official_rows=(),
|
||||
profile_rows=(),
|
||||
stock_rows=stocks,
|
||||
seat_rows=(),
|
||||
aliases={},
|
||||
)
|
||||
assert missing["status"] == "detail_missing"
|
||||
assert "1 只股票上榜" in missing["message"]
|
||||
|
||||
seats = (
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"exalter": "测试营业部",
|
||||
"buy": 20_000_000,
|
||||
"sell": 5_000_000,
|
||||
"net_buy": 15_000_000,
|
||||
},
|
||||
)
|
||||
pending = build_dragon_list(
|
||||
trade_date="2026-07-30",
|
||||
official_rows=(),
|
||||
profile_rows=(),
|
||||
stock_rows=stocks,
|
||||
seat_rows=seats,
|
||||
aliases={},
|
||||
)
|
||||
assert pending["status"] == "unclassified"
|
||||
assert pending["summary"]["unclassified_count"] == 1
|
||||
|
||||
classified = build_dragon_list(
|
||||
trade_date="2026-07-30",
|
||||
official_rows=(),
|
||||
profile_rows=(),
|
||||
stock_rows=stocks,
|
||||
seat_rows=seats,
|
||||
aliases={"测试营业部": "测试游资"},
|
||||
)
|
||||
assert classified["status"] == "success"
|
||||
assert classified["traders"][0]["name"] == "测试游资"
|
||||
|
||||
|
||||
class AuctionGateway:
|
||||
def __init__(self, dynamic_available: bool = True) -> None:
|
||||
self.dynamic_available = dynamic_available
|
||||
|
||||
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
|
||||
dates = ("2026-07-30", "2026-07-29", "2026-07-28")
|
||||
return tuple(value for value in dates if value <= through)[:limit]
|
||||
|
||||
def insight_inputs(
|
||||
self, kind: str, trade_date: str, previous: str = "", identifier: str = ""
|
||||
) -> dict:
|
||||
assert kind == "auction"
|
||||
return {
|
||||
"auction": result(()),
|
||||
"price_limits": result(({"ts_code": "000001.SZ", "up_limit": 11},)),
|
||||
"ths_hot": result(()),
|
||||
"dc_hot": result(()),
|
||||
}
|
||||
|
||||
def stock_directory(self) -> dict[str, dict]:
|
||||
return {
|
||||
"000001.SZ": {
|
||||
"identifier": "000001.SZ",
|
||||
"code": "000001",
|
||||
"name": "平安银行",
|
||||
"sector": "银行",
|
||||
}
|
||||
}
|
||||
|
||||
def dynamic_auction(
|
||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||
) -> ProviderResult:
|
||||
if not self.dynamic_available:
|
||||
raise MarketDataUnavailable("动态竞价暂不可用")
|
||||
assert identifiers == ("000001.SZ",)
|
||||
assert start_time.endswith("09:15:00")
|
||||
return result(
|
||||
(
|
||||
{
|
||||
"thscode": "000001.SZ",
|
||||
"time": end_time,
|
||||
"latest": 10.5,
|
||||
"preClose": 10,
|
||||
"volume": 1_000_000,
|
||||
"amount": 10_500_000,
|
||||
"turnoverRatio": 0.2,
|
||||
"volumeRatio": 1.5,
|
||||
},
|
||||
),
|
||||
source=DataSource.IFIND,
|
||||
state=SnapshotState.REALTIME,
|
||||
)
|
||||
|
||||
|
||||
def result(
|
||||
rows: tuple[dict, ...],
|
||||
*,
|
||||
source: DataSource = DataSource.TUSHARE,
|
||||
state: SnapshotState = SnapshotState.ARCHIVE,
|
||||
) -> ProviderResult:
|
||||
return ProviderResult(
|
||||
rows,
|
||||
ObservationMetadata(
|
||||
source=source,
|
||||
observed_at=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI),
|
||||
unit="mixed",
|
||||
adjustment="not_applicable",
|
||||
freshness_seconds=0,
|
||||
coverage=1,
|
||||
state=state,
|
||||
usage=DataUsage.CALCULATION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def insight_service(tmp_path, dynamic_available: bool = True) -> MarketInsightService:
|
||||
database = Database(tmp_path / "insights.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = MarketRepository()
|
||||
prior = {
|
||||
"trade_date": "2026-07-29",
|
||||
"limits": [
|
||||
{
|
||||
"identifier": "000001.SZ",
|
||||
"code": "000001",
|
||||
"name": "平安银行",
|
||||
"sector": "银行",
|
||||
"streak": 1,
|
||||
"amount": 200_000_000,
|
||||
}
|
||||
],
|
||||
"broken": [],
|
||||
"sectors": [{"name": "银行", "count": 1}],
|
||||
}
|
||||
with database.transaction() as connection:
|
||||
repository.save_summary(
|
||||
connection,
|
||||
trade_date="2026-07-29",
|
||||
observed_at="2026-07-29T15:00:00+08:00",
|
||||
state="final",
|
||||
source="tushare",
|
||||
coverage=1,
|
||||
payload=prior,
|
||||
)
|
||||
return MarketInsightService(
|
||||
database, repository, AuctionGateway(dynamic_available) # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_auction_lifecycle_uses_live_snapshot_then_archives_925_result(tmp_path) -> None:
|
||||
service = insight_service(tmp_path)
|
||||
observing = service.auction(
|
||||
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
|
||||
)
|
||||
assert observing["phase"] == "observing"
|
||||
assert observing["trade_date"] == "2026-07-30"
|
||||
assert observing["dynamic"] is True
|
||||
assert observing["current_available"] is True
|
||||
|
||||
selection = service.auction(
|
||||
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 26, tzinfo=SHANGHAI)
|
||||
)
|
||||
assert selection["phase"] == "selection"
|
||||
assert selection["state"] == "final"
|
||||
with service._database.read() as connection:
|
||||
stored = service._repository.insight_snapshot(
|
||||
connection, "auction", "2026-07-30"
|
||||
)
|
||||
assert stored is not None
|
||||
|
||||
|
||||
def test_observing_without_dynamic_data_never_disguises_previous_archive(tmp_path) -> None:
|
||||
service = insight_service(tmp_path, dynamic_available=False)
|
||||
previous = {
|
||||
"trade_date": "2026-07-29",
|
||||
"observed_at": "2026-07-29T09:25:00+08:00",
|
||||
"state": "archive",
|
||||
"summary": {"stock_count": 1, "amount_billion": 0.1},
|
||||
"focus_rows": [],
|
||||
"one_price_rows": [],
|
||||
"rows": [],
|
||||
"amount_history": [],
|
||||
}
|
||||
with service._database.transaction() as connection:
|
||||
service._repository.save_insight_snapshot(
|
||||
connection,
|
||||
kind="auction",
|
||||
trade_date="2026-07-29",
|
||||
entity_key="",
|
||||
observed_at=previous["observed_at"],
|
||||
state="archive",
|
||||
source="tushare",
|
||||
coverage=1,
|
||||
payload=previous,
|
||||
)
|
||||
current = service.auction(
|
||||
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
|
||||
)
|
||||
assert current["trade_date"] == "2026-07-29"
|
||||
assert current["carried_forward"] is True
|
||||
assert current["current_available"] is False
|
||||
assert "动态竞价暂不可用" in current["message"]
|
||||
Reference in New Issue
Block a user