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 from backend.features.review.repository import ReviewRepository 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 = ReviewRepository() 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, ReviewRepository(), 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"]