diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 4e845d5..898ea95 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -17,6 +17,7 @@ from backend.features.accounts.service import ( MembershipService, ) from backend.features.market import MarketService +from backend.features.market.insights import MarketInsightService from backend.features.market.sync import MarketSnapshotService from backend.security import PasswordHasher, load_or_create_cipher @@ -64,6 +65,8 @@ def build_container(settings: Settings) -> ApplicationContainer: system_credentials=credentials, model_pool=ModelPoolService(database, model_pool_repository, cipher), market=MarketService( - gateway, MarketSnapshotService(database, market_repository, gateway) + gateway, + MarketSnapshotService(database, market_repository, gateway), + MarketInsightService(database, market_repository, gateway), ), ) diff --git a/next/backend/data/gateway.py b/next/backend/data/gateway.py index e144d43..ad1e848 100644 --- a/next/backend/data/gateway.py +++ b/next/backend/data/gateway.py @@ -12,6 +12,7 @@ from backend.data.contracts import ( DataUsage, MarketEntity, ObservationMetadata, + ProviderResult, SnapshotState, TradeContext, ) @@ -114,6 +115,39 @@ class DataGateway: self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) return provider.snapshot_inputs(trade_date, previous_trade_date) + def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]: + requested = _date(through) + with self._database.read() as connection: + return self._repository.open_dates(connection, requested, limit) + + def stock_directory(self) -> dict[str, dict[str, Any]]: + with self._database.read() as connection: + rows = self._repository.stock_directory(connection) + return {str(row["identifier"]): dict(row) for row in rows} + + def insight_inputs( + self, + kind: str, + trade_date: str, + previous_trade_date: str = "", + identifier: str = "", + ) -> dict[str, Any]: + provider = self._provider(DataSource.TUSHARE) + self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) + return provider.market_insight(kind, trade_date, previous_trade_date, identifier) + + def dynamic_auction( + self, identifiers: tuple[str, ...], start_time: str, end_time: str + ) -> ProviderResult: + if not identifiers: + raise MarketDataUnavailable("动态竞价候选范围为空") + provider = self._provider(DataSource.IFIND) + self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) + result = provider.realtime_snapshots(identifiers, start_time, end_time) + if not result.rows: + raise MarketDataUnavailable("当前动态竞价快照暂不可用") + return result + def sector_members( self, trade_date: str, sector_name: str, representative: str ) -> dict[str, Any]: diff --git a/next/backend/data/policy.py b/next/backend/data/policy.py index 23fa8cf..23f4587 100644 --- a/next/backend/data/policy.py +++ b/next/backend/data/policy.py @@ -30,5 +30,7 @@ class DataSourcePolicy: ("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE), ("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY), ("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE), + ("market_insight", DataUsage.CALCULATION): (DataSource.TUSHARE,), + ("dynamic_auction", DataUsage.CALCULATION): (DataSource.IFIND,), } return routes.get((dataset, usage), ()) diff --git a/next/backend/data/providers/base.py b/next/backend/data/providers/base.py index 03b3e99..2aff656 100644 --- a/next/backend/data/providers/base.py +++ b/next/backend/data/providers/base.py @@ -28,3 +28,15 @@ class MarketDataProvider(Protocol): ) -> dict[str, ProviderResult | dict[str, Any]]: ... def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ... + + def market_insight( + self, + kind: str, + trade_date: str, + previous_trade_date: str = "", + identifier: str = "", + ) -> dict[str, ProviderResult | None]: ... + + def realtime_snapshots( + self, identifiers: tuple[str, ...], start_time: str, end_time: str + ) -> ProviderResult: ... diff --git a/next/backend/data/providers/eastmoney.py b/next/backend/data/providers/eastmoney.py index 726a6e7..3de80fd 100644 --- a/next/backend/data/providers/eastmoney.py +++ b/next/backend/data/providers/eastmoney.py @@ -102,6 +102,20 @@ class EastmoneyProvider: def sector_members(self, representative: str, trade_date: str) -> ProviderResult: raise ProviderError("The display provider is not the constituent authority") + def market_insight( + self, + kind: str, + trade_date: str, + previous_trade_date: str = "", + identifier: str = "", + ) -> dict[str, ProviderResult | None]: + raise ProviderError("The display provider cannot supply market insight archives") + + def realtime_snapshots( + self, identifiers: tuple[str, ...], start_time: str, end_time: str + ) -> ProviderResult: + raise ProviderError("The display provider cannot supply calculation snapshots") + @staticmethod def _secid(entity_type: str, identifier: str) -> str: if entity_type == "index" and identifier in INDEX_CODES: diff --git a/next/backend/data/providers/ifind.py b/next/backend/data/providers/ifind.py index 7389277..068bf9a 100644 --- a/next/backend/data/providers/ifind.py +++ b/next/backend/data/providers/ifind.py @@ -92,6 +92,51 @@ class IfindProvider: def sector_members(self, representative: str, trade_date: str) -> ProviderResult: raise ProviderError("iFinD is not the Shenwan constituent authority") + def market_insight( + self, + kind: str, + trade_date: str, + previous_trade_date: str = "", + identifier: str = "", + ) -> dict[str, ProviderResult | None]: + raise ProviderError("iFinD只承担许可范围内的动态竞价快照") + + def realtime_snapshots( + self, identifiers: tuple[str, ...], start_time: str, end_time: str + ) -> ProviderResult: + rows: list[dict[str, Any]] = [] + for offset in range(0, len(identifiers), 80): + batch = identifiers[offset : offset + 80] + if not batch: + continue + payload = self._request( + "snap_shot", + { + "codes": ",".join(batch), + "indicators": "latest,volume,amount,preClose,turnoverRatio,volumeRatio," + "bid1,bidSize1,ask1,askSize1", + "starttime": start_time, + "endtime": end_time, + }, + ) + rows.extend(_result(payload, "mixed", "not_applicable", SnapshotState.REALTIME).rows) + covered = { + str(row.get("thscode") or "") for row in rows if row.get("thscode") + } + return ProviderResult( + tuple(rows), + ObservationMetadata( + source=self.source, + observed_at=datetime.now(SHANGHAI), + unit="mixed", + adjustment="not_applicable", + freshness_seconds=0, + coverage=min(len(covered) / max(len(identifiers), 1), 1), + state=SnapshotState.REALTIME, + usage=DataUsage.CALCULATION, + ), + ) + def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: if not self.configured: raise ProviderError("实时行情服务尚未配置") diff --git a/next/backend/data/providers/tushare.py b/next/backend/data/providers/tushare.py index 60993c1..0dbceef 100644 --- a/next/backend/data/providers/tushare.py +++ b/next/backend/data/providers/tushare.py @@ -189,6 +189,109 @@ class TushareProvider: coverage = sum(bool(row["quoted"]) for row in rows) / len(rows) return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage)) + def market_insight( + self, + kind: str, + trade_date: str, + previous_trade_date: str = "", + identifier: str = "", + ) -> dict[str, ProviderResult | None]: + current = _compact(trade_date) + previous = _compact(previous_trade_date) if previous_trade_date else current + if kind == "auction": + return { + "auction": self._optional_query( + "stk_auction", + {"trade_date": current}, + "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate," + "volume_ratio,float_share", + ), + "price_limits": self._optional_query( + "stk_limit", + {"trade_date": current}, + "trade_date,ts_code,up_limit,down_limit", + ), + "ths_hot": self._optional_query("ths_hot", {"trade_date": previous}, ""), + "dc_hot": self._optional_query("dc_hot", {"trade_date": previous}, ""), + } + if kind == "themes": + return { + "directory": self._optional_query( + "ths_index", {}, "ts_code,name,count,exchange,list_date,type" + ), + "daily": self._optional_query( + "ths_daily", + {"trade_date": current}, + "ts_code,trade_date,open,high,low,close,pre_close,pct_change," + "vol,turnover_rate", + ), + "hot": self._optional_query("ths_hot", {"trade_date": current}, ""), + } + if kind == "theme-detail": + return { + "members": self._optional_query( + "ths_member", + {"ts_code": identifier, "is_new": "Y"}, + "ts_code,con_code,con_name", + ), + "daily": self._optional_query( + "daily", + {"trade_date": current}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ), + } + if kind == "popularity": + return { + "ths": self._optional_query("ths_hot", {"trade_date": current}, ""), + "dc": self._optional_query("dc_hot", {"trade_date": current}, ""), + "previous_ths": self._optional_query( + "ths_hot", {"trade_date": previous}, "" + ), + "previous_dc": self._optional_query( + "dc_hot", {"trade_date": previous}, "" + ), + } + if kind == "dragon-list": + return { + "official": self._optional_query( + "hm_detail", + {"trade_date": current}, + "trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount," + "hm_name,hm_orgs,tag", + ), + "profiles": self._optional_query("hm_list", {}, "name,desc,orgs"), + "stocks": self._optional_query( + "top_list", + {"trade_date": current}, + "trade_date,ts_code,name,pct_change,reason", + ), + "seats": self._optional_query( + "top_inst", + {"trade_date": current}, + "trade_date,ts_code,exalter,buy,sell,net_buy,side,reason", + ), + } + raise ProviderError("不支持的市场洞察数据集") + + def realtime_snapshots( + self, identifiers: tuple[str, ...], start_time: str, end_time: str + ) -> ProviderResult: + raise ProviderError("Tushare不提供动态竞价快照") + + def _optional_query( + self, api_name: str, params: dict[str, Any], fields: str + ) -> ProviderResult | None: + try: + return self._query( + api_name, + params, + fields, + unit="mixed", + empty_is_complete=True, + ) + except ProviderError: + return None + def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] fields = ( diff --git a/next/backend/data/repository.py b/next/backend/data/repository.py index b99e5c5..8f513b1 100644 --- a/next/backend/data/repository.py +++ b/next/backend/data/repository.py @@ -79,6 +79,43 @@ class MarketRepository: ], ) + def replace_themes( + self, + connection: sqlite3.Connection, + rows: list[dict[str, Any]], + source: str, + observed_at: str, + ) -> None: + connection.executemany( + """ + INSERT INTO market_entities ( + entity_type, identifier, code, name, search_key, + sector, active, source, observed_at + ) VALUES ('theme', ?, ?, ?, ?, NULL, 1, ?, ?) + ON CONFLICT(entity_type, identifier) DO UPDATE SET + code = excluded.code, + name = excluded.name, + search_key = excluded.search_key, + active = 1, + source = excluded.source, + observed_at = excluded.observed_at + """, + [ + ( + str(row.get("code") or "").upper(), + str(row.get("code") or "").split(".")[0], + str(row.get("name") or "").strip(), + _normalize( + f"{row.get('code') or ''} {row.get('name') or ''}" + ), + source, + observed_at, + ) + for row in rows + if row.get("code") and row.get("name") + ], + ) + def search( self, connection: sqlite3.Connection, query: str, limit: int = 32 ) -> tuple[MarketEntity, ...]: @@ -135,6 +172,16 @@ class MarketRepository: ).fetchone() return int(row["count"] if row else 0) + def stock_directory(self, connection: sqlite3.Connection) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT identifier, code, name, sector FROM market_entities + WHERE entity_type = 'stock' AND active = 1 + """ + ).fetchall() + ) + def save_summary( self, connection: sqlite3.Connection, @@ -234,6 +281,128 @@ class MarketRepository: ), ) + def insight_snapshot( + self, + connection: sqlite3.Connection, + kind: str, + trade_date: str, + entity_key: str = "", + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM market_insight_snapshots + WHERE kind = ? AND trade_date = ? AND entity_key = ? + """, + (kind, trade_date, entity_key), + ).fetchone() + + def latest_insight_snapshot( + self, + connection: sqlite3.Connection, + kind: str, + through: str, + entity_key: str = "", + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM market_insight_snapshots + WHERE kind = ? AND trade_date <= ? AND entity_key = ? + ORDER BY trade_date DESC LIMIT 1 + """, + (kind, through, entity_key), + ).fetchone() + + def insight_snapshots( + self, connection: sqlite3.Connection, kind: str, through: str, limit: int + ) -> tuple[sqlite3.Row, ...]: + rows = connection.execute( + """ + SELECT * FROM market_insight_snapshots + WHERE kind = ? AND trade_date <= ? AND entity_key = '' + ORDER BY trade_date DESC LIMIT ? + """, + (kind, through, limit), + ).fetchall() + return tuple(reversed(rows)) + + def save_insight_snapshot( + self, + connection: sqlite3.Connection, + *, + kind: str, + trade_date: str, + entity_key: str, + observed_at: str, + state: str, + source: str, + coverage: float, + payload: dict[str, Any], + ) -> None: + connection.execute( + """ + INSERT INTO market_insight_snapshots ( + kind, trade_date, entity_key, observed_at, state, source, coverage, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(kind, trade_date, entity_key) DO UPDATE SET + observed_at = excluded.observed_at, + state = excluded.state, + source = excluded.source, + coverage = excluded.coverage, + payload_json = excluded.payload_json + """, + ( + kind, + trade_date, + entity_key, + observed_at, + state, + source, + coverage, + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ), + ) + + def seat_aliases(self, connection: sqlite3.Connection) -> dict[str, str]: + return { + str(row["seat_name"]): str(row["alias_name"]) + for row in connection.execute( + "SELECT seat_name, alias_name FROM seat_aliases ORDER BY seat_name" + ).fetchall() + } + + def save_seat_alias( + self, + connection: sqlite3.Connection, + seat_name: str, + alias_name: str, + updated_at: str, + updated_by: int, + ) -> None: + connection.execute( + """ + INSERT INTO seat_aliases (seat_name, alias_name, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(seat_name) DO UPDATE SET + alias_name = excluded.alias_name, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (seat_name, alias_name, updated_at, updated_by), + ) + + def watchlist( + self, connection: sqlite3.Connection, user_id: int + ) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT identifier, name, sector FROM watchlist_entries + WHERE user_id = ? ORDER BY created_at, identifier + """, + (user_id,), + ).fetchall() + ) + def save_chart( self, connection: sqlite3.Connection, diff --git a/next/backend/database/migrations/m0005_market_insights.py b/next/backend/database/migrations/m0005_market_insights.py new file mode 100644 index 0000000..b53f406 --- /dev/null +++ b/next/backend/database/migrations/m0005_market_insights.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE market_insight_snapshots ( + kind TEXT NOT NULL CHECK ( + kind IN ('auction', 'themes', 'popularity', 'dragon-list') + ), + trade_date TEXT NOT NULL, + entity_key TEXT NOT NULL DEFAULT '', + observed_at TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('realtime', 'final', 'archive')), + source TEXT NOT NULL, + coverage REAL NOT NULL CHECK (coverage >= 0 AND coverage <= 1), + payload_json TEXT NOT NULL, + PRIMARY KEY (kind, trade_date, entity_key) + ) + """ + ) + connection.execute( + """ + CREATE TABLE seat_aliases ( + seat_name TEXT PRIMARY KEY, + alias_name TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + +def downgrade(connection: sqlite3.Connection) -> None: + connection.execute("DROP TABLE seat_aliases") + connection.execute("DROP TABLE market_insight_snapshots") + + +MIGRATION = Migration( + version=5, + name="create_market_insight_archives", + signature="market:v3:auction-themes-popularity-dragon-list", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/m0006_watchlists.py b/next/backend/database/migrations/m0006_watchlists.py new file mode 100644 index 0000000..59fa40c --- /dev/null +++ b/next/backend/database/migrations/m0006_watchlists.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE watchlist_entries ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + identifier TEXT NOT NULL, + name TEXT NOT NULL, + sector TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, identifier) + ) + """ + ) + + +def downgrade(connection: sqlite3.Connection) -> None: + connection.execute("DROP TABLE watchlist_entries") + + +MIGRATION = Migration( + version=6, + name="create_account_watchlists", + signature="accounts:v2:isolated-watchlist-entries", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index 2e3c4a7..923c338 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -2,6 +2,15 @@ from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION from backend.database.migrations.m0004_sector_members import MIGRATION as SECTOR_MEMBERS +from backend.database.migrations.m0005_market_insights import MIGRATION as MARKET_INSIGHTS +from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS from backend.database.migrations.runner import Migration -MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION, SECTOR_MEMBERS) +MIGRATIONS: tuple[Migration, ...] = ( + ACCOUNTS, + MODEL_POOL, + MARKET_FOUNDATION, + SECTOR_MEMBERS, + MARKET_INSIGHTS, + WATCHLISTS, +) diff --git a/next/backend/features/market/insights/__init__.py b/next/backend/features/market/insights/__init__.py new file mode 100644 index 0000000..ffe07e0 --- /dev/null +++ b/next/backend/features/market/insights/__init__.py @@ -0,0 +1,3 @@ +from backend.features.market.insights.service import MarketInsightService + +__all__ = ["MarketInsightService"] diff --git a/next/backend/features/market/insights/auction.py b/next/backend/features/market/insights/auction.py new file mode 100644 index 0000000..0ce606d --- /dev/null +++ b/next/backend/features/market/insights/auction.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import json +from statistics import median +from typing import Any + + +def build_auction( + *, + trade_date: str, + raw_rows: tuple[dict[str, Any], ...], + price_limits: tuple[dict[str, Any], ...], + directory: dict[str, dict[str, Any]], + prior_snapshot: dict[str, Any], + ths_hot: tuple[dict[str, Any], ...], + dc_hot: tuple[dict[str, Any], ...], + history: list[dict[str, Any]], + dynamic: bool, +) -> dict[str, Any]: + rows = _normalize_rows(raw_rows, price_limits, directory, dynamic) + candidates, focus_rows = _score_candidates(rows, prior_snapshot, ths_hot, dc_hot) + scored = {str(item["code"]): item for item in candidates} + candidate_codes = {str(item["code"]) for item in candidates} + one_price_rows = [ + { + **item, + **scored.get(str(item["code"]), {}), + "attention_score": None, + "expectation": "", + "expected_change": None, + "expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离", + } + for item in rows + if item["is_one_price"] + ] + one_price_codes = {str(item["code"]) for item in one_price_rows} + candidates = [item for item in candidates if item["code"] not in one_price_codes] + focus_rows = [item for item in focus_rows if item["code"] not in one_price_codes] + one_price_rows.sort( + key=lambda item: ( + bool(item.get("is_market_core")), + _number(item.get("prior_streak")), + _number(item.get("amount_million")), + ), + reverse=True, + ) + changes = [float(item["change"]) for item in rows] + amount_billion = round(sum(float(item["amount_million"]) for item in rows) / 100, 2) + amount_history = [item for item in history if item.get("trade_date") != trade_date][-9:] + amount_history.append( + {"trade_date": trade_date, "amount_billion": amount_billion, "stock_count": len(rows)} + ) + prior_amounts = [float(item["amount_billion"]) for item in amount_history[:-1]] + previous_amount = prior_amounts[-1] if prior_amounts else 0 + five_day = prior_amounts[-5:] + five_day_average = sum(five_day) / len(five_day) if five_day else 0 + eligible = sum( + bool(item.get("identifier")) + and not str(item.get("name") or "").upper().startswith(("N", "C")) + for item in directory.values() + ) + coverage = min(len(rows) / max(eligible, 1), 1) + expectations = { + label: sum(item.get("expectation") == label for item in candidates) + for label in ("超预期", "符合预期", "低于预期") + } + return { + "trade_date": trade_date, + "dynamic": dynamic, + "coverage": round(coverage, 4), + "summary": { + "stock_count": len(rows), + "candidate_count": len(candidates), + "focus_count": len(focus_rows), + "one_price_count": len(one_price_rows), + "amount_billion": amount_billion, + "amount_change_previous": ( + round((amount_billion / previous_amount - 1) * 100, 1) + if previous_amount + else None + ), + "amount_change_5d": ( + round((amount_billion / five_day_average - 1) * 100, 1) + if five_day_average + else None + ), + "median_change": round(median(changes), 2) if changes else None, + }, + "expectations": expectations, + "themes": _theme_evidence(prior_snapshot, candidates + one_price_rows), + "amount_history": amount_history, + "focus_rows": focus_rows, + "one_price_rows": one_price_rows, + "_market_rows": rows, + "rows": candidates, + "all_market_count": len(rows), + "candidate_market_count": len(candidate_codes), + } + + +def build_watchlist_rows( + market_rows: list[dict[str, Any]], + candidates: list[dict[str, Any]], + one_price_rows: list[dict[str, Any]], + watchlist: tuple[dict[str, Any], ...], +) -> list[dict[str, Any]]: + market = {str(item["identifier"]): item for item in market_rows} + enriched = { + str(item["identifier"]): item for item in candidates + one_price_rows + } + result = [] + for saved in watchlist: + identifier = str(saved.get("identifier") or "") + row = enriched.get(identifier) + if row: + result.append({**row, "is_watchlist": True, "available": True}) + continue + raw = market.get(identifier) + if raw: + actual = _number(raw.get("change")) + _confirmation(raw) + item = { + **raw, + "candidate_sources": ["我的自选"], + "source_label": "我的自选", + "prior_streak": 0, + "concepts": [], + "expected_change": 0.0, + "actual_strength": round(actual, 2), + "expectation": _expectation(actual, 0), + "core_tags": [], + "is_market_core": False, + "is_watchlist": True, + "available": True, + } + item["attention_score"] = _attention(item, 0, False, False) + item["expectation_reason"] = "自选观察,按当日竞价强度与成交确认评估" + result.append(item) + continue + result.append( + { + "identifier": identifier, + "code": identifier.split(".")[0], + "name": str(saved.get("name") or ""), + "sector": str(saved.get("sector") or ""), + "is_watchlist": True, + "available": False, + } + ) + return sorted( + result, + key=lambda item: ( + bool(item.get("available")), + _number(item.get("attention_score")), + ), + reverse=True, + ) + + +def _normalize_rows( + raw_rows: tuple[dict[str, Any], ...], + price_limits: tuple[dict[str, Any], ...], + directory: dict[str, dict[str, Any]], + dynamic: bool, +) -> list[dict[str, Any]]: + limits = {str(row.get("ts_code") or ""): row for row in price_limits} + latest: dict[str, dict[str, Any]] = {} + for raw in raw_rows: + identifier = str(raw.get("thscode") or raw.get("ts_code") or "").upper() + if not identifier or identifier not in directory: + continue + previous = latest.get(identifier) + if previous is None or str(raw.get("time") or "") >= str(previous.get("time") or ""): + latest[identifier] = raw + rows = [] + for identifier, raw in latest.items(): + stock = directory[identifier] + price = _number(raw.get("latest" if dynamic else "price")) + pre_close = _number(raw.get("preClose" if dynamic else "pre_close")) + volume = _number(raw.get("volume" if dynamic else "vol")) + amount = _number(raw.get("amount")) + if amount <= 0 and price > 0 and volume > 0: + amount = price * volume + if price <= 0 or pre_close <= 0: + continue + change = (price / pre_close - 1) * 100 + up_limit = _number((limits.get(identifier) or {}).get("up_limit")) + rows.append( + { + "identifier": identifier, + "code": str(stock.get("code") or identifier.split(".")[0]), + "name": str(stock.get("name") or ""), + "sector": str(stock.get("sector") or "其他"), + "price": round(price, 2), + "change": round(change, 2), + "amount_million": round(amount / 1_000_000, 2), + "turnover_rate": round( + _number(raw.get("turnoverRatio" if dynamic else "turnover_rate")), 4 + ), + "volume_ratio": round( + _number(raw.get("volumeRatio" if dynamic else "volume_ratio")), 2 + ), + "up_limit": round(up_limit, 2) if up_limit else None, + "is_one_price": bool( + up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005) + ), + "snapshot_time": str(raw.get("time") or ""), + } + ) + rows.sort( + key=lambda item: (float(item["amount_million"]), float(item["volume_ratio"])), + reverse=True, + ) + return rows + + +def _score_candidates( + rows: list[dict[str, Any]], + prior_snapshot: dict[str, Any], + ths_hot: tuple[dict[str, Any], ...], + dc_hot: tuple[dict[str, Any], ...], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + prior_limits = list(prior_snapshot.get("limits") or []) + prior_broken = list(prior_snapshot.get("broken") or []) + prior_sectors = list(prior_snapshot.get("sectors") or []) + strong_sectors = {str(item.get("name") or "") for item in prior_sectors[:5]} + identities: dict[str, dict[str, Any]] = {} + core_tags: dict[str, set[str]] = {} + + def ensure(item: dict[str, Any]) -> tuple[str, dict[str, Any]] | None: + code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0]) + if not code: + return None + return code, identities.setdefault( + code, + { + "sources": [], + "streak": 0, + "sector": str(item.get("sector") or "其他"), + "concepts": [], + "ths_rank": None, + "dc_rank": None, + }, + ) + + highest = max((int(_number(item.get("streak"), 1)) for item in prior_limits), default=0) + for item in prior_limits: + entry = ensure(item) + if not entry: + continue + code, identity = entry + streak = max(1, int(_number(item.get("streak"), 1))) + identity["streak"] = streak + identity["sources"].append("昨日涨停") + if streak >= 3: + core_tags.setdefault(code, set()).add("三板以上") + if highest and streak == highest: + core_tags.setdefault(code, set()).add("市场最高板") + for item in prior_broken: + entry = ensure(item) + if entry and "昨日炸板" not in entry[1]["sources"]: + entry[1]["sources"].append("昨日炸板") + for sector in prior_sectors[:5]: + name = str(sector.get("name") or "") + members = [item for item in prior_limits if str(item.get("sector") or "") == name] + if members: + leader = max( + members, + key=lambda item: ( + int(_number(item.get("streak"), 1)), + _number(item.get("amount")), + ), + ) + core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心") + if prior_limits: + leader = max( + prior_limits, + key=lambda item: ( + int(_number(item.get("streak"), 1)), + str(item.get("sector") or "") in strong_sectors, + _number(item.get("amount")), + ), + ) + core_tags.setdefault(str(leader.get("code") or ""), set()).add("市场领涨") + + hot_records: dict[str, dict[str, Any]] = {} + for rows_source, source, expected_type, rank_key in ( + (ths_hot, "同花顺热榜", "热股", "ths_rank"), + (dc_hot, "东方财富热榜", "A股市场", "dc_rank"), + ): + for item in rows_source: + if str(item.get("data_type") or "") != expected_type: + continue + code = str(item.get("ts_code") or "").split(".")[0] + rank = max(1, int(_number(item.get("rank"), 9999))) + if not code or rank > 20: + continue + hot = hot_records.setdefault( + code, {"ths_rank": None, "dc_rank": None, "concepts": []} + ) + hot[rank_key] = rank + if rank_key == "ths_rank": + hot["concepts"] = _concepts(item.get("concept")) + identity = identities.setdefault( + code, + { + "sources": [], + "streak": 0, + "sector": "其他", + "concepts": [], + "ths_rank": None, + "dc_rank": None, + }, + ) + identity[rank_key] = rank + identity["concepts"] = hot["concepts"] or identity["concepts"] + if source not in identity["sources"]: + identity["sources"].append(source) + hot_ranked = sorted( + hot_records, + key=lambda code: ( + (21 - (hot_records[code]["ths_rank"] or 21)) * 0.5 + + (21 - (hot_records[code]["dc_rank"] or 21)) * 0.25 + + (10 if hot_records[code]["ths_rank"] and hot_records[code]["dc_rank"] else 0) + ), + reverse=True, + ) + for code in hot_ranked[:5]: + core_tags.setdefault(code, set()).add("人气前5") + + normalized = [] + for row in rows: + identity = identities.get(str(row["code"])) + if not identity: + continue + ranks = [ + rank + for rank in (identity.get("ths_rank"), identity.get("dc_rank")) + if isinstance(rank, int) + ] + if ranks and min(ranks) > 10 and len(ranks) == 1 and row["code"] not in core_tags: + if not any(source in {"昨日涨停", "昨日炸板"} for source in identity["sources"]): + continue + streak = int(identity["streak"]) + expected = {0: 0.5, 1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0) + expected += 0.8 if len(ranks) == 2 else 0.7 if ranks and min(ranks) <= 10 else 0 + expected = min(expected, 6.5) + confirmation = _confirmation(row) + actual_strength = float(row["change"]) + confirmation + expectation = _expectation(actual_strength, expected) + tags = sorted(core_tags.get(str(row["code"]), set())) + scored = { + **row, + "sector": identity["sector"] if identity["sector"] != "其他" else row["sector"], + "candidate_sources": identity["sources"], + "source_label": " · ".join(identity["sources"]), + "prior_streak": streak, + "concepts": identity["concepts"], + "expected_change": round(expected, 2), + "actual_strength": round(actual_strength, 2), + "expectation": expectation, + "core_tags": tags, + "is_market_core": bool(tags), + } + scored["attention_score"] = _attention( + scored, + expected, + bool(tags), + str(scored["sector"]) in strong_sectors, + ) + scored["expectation_reason"] = _reason(scored) + normalized.append(scored) + normalized.sort( + key=lambda item: (float(item["attention_score"]), float(item["amount_million"])), + reverse=True, + ) + matched = { + str(item["code"]) + for item in [row for row in normalized if row["expectation"] == "符合预期"][:20] + } + mandatory = [item for item in normalized if item["is_market_core"]] + optional = [ + item + for item in normalized + if not item["is_market_core"] + and ( + (item["attention_score"] >= 55 and item["expectation"] != "符合预期") + or item["code"] in matched + ) + ] + focus = mandatory + optional[: max(0, 30 - len(mandatory))] + focus.sort(key=lambda item: float(item["attention_score"]), reverse=True) + return normalized, focus + + +def _confirmation(row: dict[str, Any]) -> float: + volume_ratio = _number(row.get("volume_ratio")) + turnover = _number(row.get("turnover_rate")) + amount = _number(row.get("amount_million")) + return ( + ( + 0.6 + if volume_ratio >= 2 + else 0.3 + if volume_ratio >= 1.2 + else -0.5 + if volume_ratio < 0.6 + else 0 + ) + + (0.25 if turnover >= 0.15 else -0.25 if turnover < 0.03 else 0) + + (0.3 if amount >= 20 else 0.15 if amount >= 5 else -0.3 if amount < 1 else 0) + ) + + +def _attention( + row: dict[str, Any], expected: float, core: bool, strong_sector: bool +) -> float: + sources = list(row.get("candidate_sources") or []) + streak = int(row.get("prior_streak") or 0) + identity = 35 if core else 27 if streak >= 2 else 21 if sources else 14 + deviation = min(30, abs(_number(row.get("change")) - expected) * 5) + volume = min(10, max(0, _number(row.get("volume_ratio"))) / 2 * 10) + amount = min(6, max(0, _number(row.get("amount_million"))) / 10 * 6) + turnover = min(4, max(0, _number(row.get("turnover_rate"))) / 0.2 * 4) + theme = 15 if strong_sector else 7 if row.get("concepts") else 0 + return round(min(100, identity + deviation + volume + amount + turnover + theme), 1) + + +def _expectation(actual: float, expected: float) -> str: + difference = actual - expected + return "超预期" if difference >= 1.5 else "低于预期" if difference <= -1.5 else "符合预期" + + +def _reason(row: dict[str, Any]) -> str: + streak = int(row.get("prior_streak") or 0) + identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak else "热榜标的" + difference = _number(row.get("change")) - _number(row.get("expected_change")) + direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合" + return f"{identity},竞价涨幅{direction}预期{abs(difference):.1f}个百分点" + + +def _theme_evidence( + prior_snapshot: dict[str, Any], rows: list[dict[str, Any]] +) -> dict[str, list[dict[str, Any]]]: + prior_sectors = list(prior_snapshot.get("sectors") or []) + carry = [] + for sector in prior_sectors[:10]: + name = str(sector.get("name") or "其他") + members = [row for row in rows if str(row.get("sector") or "其他") == name] + changes = [_number(row.get("change")) for row in members] + middle = median(changes) if changes else None + positive = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0 + status = ( + "强承接" if middle is not None and middle >= 2 and positive >= 60 + else "有承接" if middle is not None and middle >= 0 and positive >= 50 + else "分歧" if middle is not None and middle > -2 + else "承接弱" + ) + carry.append( + { + "name": name, + "status": status, + "prior_limit_count": int(_number(sector.get("count"))), + "matched_count": len(members), + "median_change": round(middle, 2) if middle is not None else None, + "positive_rate": round(positive, 1), + } + ) + prior_names = {str(item.get("name") or "") for item in prior_sectors} + groups: dict[str, dict[str, dict[str, Any]]] = {} + for row in rows: + for concept in row.get("concepts") or []: + if concept and concept not in prior_names: + groups.setdefault(str(concept), {})[str(row["code"])] = row + new_themes = [] + for name, mapped in groups.items(): + members = list(mapped.values()) + changes = [_number(item.get("change")) for item in members] + positive_rate = sum(value > 0.2 for value in changes) / len(changes) + if len(members) >= 2 and median(changes) >= 2 and positive_rate >= 0.67: + new_themes.append( + { + "name": name, + "stock_count": len(members), + "median_change": round(median(changes), 2), + "leaders": [ + str(item.get("name") or "") + for item in sorted( + members, + key=lambda item: _number(item.get("change")), + reverse=True, + )[:3] + ], + } + ) + new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"]), reverse=True) + return {"carry": carry, "new_themes": new_themes[:8]} + + +def _concepts(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value or "").strip() + if not text: + return [] + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + except json.JSONDecodeError: + pass + return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()] + + +def _number(value: Any, default: float = 0.0) -> float: + try: + number = float(value) + return number if number == number else default + except (TypeError, ValueError): + return default diff --git a/next/backend/features/market/insights/dragon.py b/next/backend/features/market/insights/dragon.py new file mode 100644 index 0000000..ee411a2 --- /dev/null +++ b/next/backend/features/market/insights/dragon.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +def profiles(rows: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]: + result = [] + seen = set() + for row in rows: + name = _text(row.get("name")) + if not name or name in seen: + continue + seen.add(name) + organizations = _organizations(row.get("orgs")) + result.append( + { + "name": name, + "description": _text(row.get("desc")), + "organizations": organizations, + "organization_count": len(organizations), + } + ) + return result + + +def build_dragon_list( + *, + trade_date: str, + official_rows: tuple[dict[str, Any], ...] | None, + profile_rows: tuple[dict[str, Any], ...] | None, + stock_rows: tuple[dict[str, Any], ...] | None, + seat_rows: tuple[dict[str, Any], ...] | None, + aliases: dict[str, str], +) -> dict[str, Any]: + profile_items = profiles(profile_rows or ()) + profile_map = {str(item["name"]): item for item in profile_items} + organization_map = { + organization: str(item["name"]) + for item in profile_items + for organization in item["organizations"] + } + stocks = _stock_context(stock_rows or ()) + operations = _official_operations(official_rows or (), profile_map, stocks) + official_keys = { + (str(item["identifier"]), str(item["seat_name"]), round(float(item["net_million"]), 2)) + for item in operations + } + for row in seat_rows or (): + operation = _seat_operation(row, stocks, aliases, organization_map) + key = ( + str(operation["identifier"]), + str(operation["seat_name"]), + round(float(operation["net_million"]), 2), + ) + if key not in official_keys: + operations.append(operation) + + traders = _aggregate_traders(operations, profile_map) + unclassified = _aggregate_unclassified(operations) + official_stock_count = len(stocks) + detail_available = official_rows is not None or seat_rows is not None + detail_count = len(official_rows or ()) + len(seat_rows or ()) + recognized_count = sum(bool(item["recognized"]) for item in operations) + if official_rows is None and stock_rows is None and seat_rows is None: + status = "unavailable" + message = "龙虎榜数据请求失败,请稍后重新检查" + elif official_stock_count == 0 and detail_count == 0: + status = "empty" + message = "该交易日没有股票上榜" + elif official_stock_count > 0 and (not detail_available or detail_count == 0): + status = "detail_missing" + message = f"当日有 {official_stock_count} 只股票上榜,但席位明细尚未返回" + elif detail_count > 0 and recognized_count == 0: + status = "unclassified" + message = f"当日有 {official_stock_count} 只股票上榜,席位均待归类" + else: + status = "success" if not unclassified else "partial" + message = "部分营业部尚未归类" if unclassified else "" + return { + "trade_date": trade_date, + "status": status, + "message": message, + "summary": { + "official_stock_count": official_stock_count, + "trader_count": len(traders), + "operation_count": len(operations), + "unclassified_count": len(unclassified), + "net_million": round( + sum(float(item["net_million"]) for item in operations), 2 + ), + "profile_count": len(profile_items), + }, + "traders": traders, + "operations": sorted( + operations, key=lambda item: abs(float(item["net_million"])), reverse=True + ), + "unclassified_seats": unclassified, + "profiles": profile_items, + } + + +def _stock_context(rows: tuple[dict[str, Any], ...]) -> dict[str, dict[str, Any]]: + result = {} + for row in rows: + identifier = str(row.get("ts_code") or "") + if identifier and identifier not in result: + result[identifier] = { + "name": _text(row.get("name")), + "change": _optional_number(row.get("pct_change")), + "reason": _text(row.get("reason")), + } + return result + + +def _official_operations( + rows: tuple[dict[str, Any], ...], + profile_map: dict[str, dict[str, Any]], + stocks: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + result = [] + for row in rows: + identifier = str(row.get("ts_code") or "") + trader = _text(row.get("hm_name")) or "未命名游资" + profile = profile_map.get(trader) or {} + seat = _text(row.get("hm_orgs")) or "" + stock = stocks.get(identifier) or {} + result.append( + _operation( + identifier=identifier, + name=_text(row.get("ts_name")) or str(stock.get("name") or ""), + change=stock.get("change"), + reason=str(stock.get("reason") or ""), + seat_name=seat or "未提供营业部", + trader_name=trader, + description=str(profile.get("description") or ""), + buy=_number(row.get("buy_amount")) / 1_000_000, + sell=_number(row.get("sell_amount")) / 1_000_000, + net=_number(row.get("net_amount")) / 1_000_000, + recognized=True, + ) + ) + return result + + +def _seat_operation( + row: dict[str, Any], + stocks: dict[str, dict[str, Any]], + aliases: dict[str, str], + organization_map: dict[str, str], +) -> dict[str, Any]: + identifier = str(row.get("ts_code") or "") + seat = _text(row.get("exalter")) or "未命名营业部" + trader = aliases.get(seat) or organization_map.get(seat) or "" + stock = stocks.get(identifier) or {} + buy = _number(row.get("buy")) / 1_000_000 + sell = _number(row.get("sell")) / 1_000_000 + net = _number(row.get("net_buy")) / 1_000_000 + if net == 0 and (buy or sell): + net = buy - sell + return _operation( + identifier=identifier, + name=str(stock.get("name") or ""), + change=stock.get("change"), + reason=_text(row.get("reason")) or str(stock.get("reason") or ""), + seat_name=seat, + trader_name=trader, + description="", + buy=buy, + sell=sell, + net=net, + recognized=bool(trader), + ) + + +def _operation( + *, + identifier: str, + name: str, + change: float | None, + reason: str, + seat_name: str, + trader_name: str, + description: str, + buy: float, + sell: float, + net: float, + recognized: bool, +) -> dict[str, Any]: + return { + "identifier": identifier, + "code": identifier.split(".")[0], + "name": name, + "change": change, + "direction": "买入" if net > 0 else "卖出" if net < 0 else "持平", + "buy_million": round(buy, 2), + "sell_million": round(sell, 2), + "net_million": round(net, 2), + "seat_name": seat_name, + "trader_name": trader_name, + "description": description, + "reason": reason, + "recognized": recognized, + } + + +def _aggregate_traders( + operations: list[dict[str, Any]], profile_map: dict[str, dict[str, Any]] +) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for operation in operations: + name = str(operation.get("trader_name") or "") + if not operation.get("recognized") or not name: + continue + group = groups.setdefault( + name, + { + "name": name, + "description": str((profile_map.get(name) or {}).get("description") or ""), + "buy_million": 0.0, + "sell_million": 0.0, + "net_million": 0.0, + "seats": set(), + "stocks": set(), + "operations": [], + }, + ) + group["buy_million"] += float(operation["buy_million"]) + group["sell_million"] += float(operation["sell_million"]) + group["net_million"] += float(operation["net_million"]) + group["seats"].add(str(operation["seat_name"])) + group["stocks"].add(str(operation["code"])) + group["operations"].append(operation) + result = [] + for group in groups.values(): + result.append( + { + "name": group["name"], + "description": group["description"], + "buy_million": round(group["buy_million"], 2), + "sell_million": round(group["sell_million"], 2), + "net_million": round(group["net_million"], 2), + "seat_count": len(group["seats"]), + "stock_count": len(group["stocks"]), + "operation_count": len(group["operations"]), + "operations": sorted( + group["operations"], + key=lambda item: abs(float(item["net_million"])), + reverse=True, + ), + } + ) + return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True) + + +def _aggregate_unclassified(operations: list[dict[str, Any]]) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for operation in operations: + if operation.get("recognized"): + continue + seat = str(operation["seat_name"]) + group = groups.setdefault( + seat, {"seat_name": seat, "net_million": 0.0, "operation_count": 0} + ) + group["net_million"] += float(operation["net_million"]) + group["operation_count"] += 1 + result = [ + {**group, "net_million": round(float(group["net_million"]), 2)} + for group in groups.values() + ] + return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True) + + +def _organizations(value: Any) -> list[str]: + text = _text(value) + parsed: Any = None + if text.startswith("["): + try: + parsed = json.loads(text) + except json.JSONDecodeError: + parsed = None + values = parsed if isinstance(parsed, list) else re.split(r"[,,;;\n]+", text) + return list(dict.fromkeys(_text(item) for item in values if _text(item))) + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _number(value: Any) -> float: + try: + number = float(value) + return number if number == number else 0.0 + except (TypeError, ValueError): + return 0.0 + + +def _optional_number(value: Any) -> float | None: + if value in (None, ""): + return None + return _number(value) diff --git a/next/backend/features/market/insights/popularity.py b/next/backend/features/market/insights/popularity.py new file mode 100644 index 0000000..85444c8 --- /dev/null +++ b/next/backend/features/market/insights/popularity.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +from typing import Any + + +def build_popularity( + trade_date: str, + ths_rows: tuple[dict[str, Any], ...], + dc_rows: tuple[dict[str, Any], ...], + previous_ths: tuple[dict[str, Any], ...], + previous_dc: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + ths = _normalize(ths_rows, "热股", previous_ths) + dc = _normalize(dc_rows, "A股市场", previous_dc) + ths_map = {str(item["identifier"]): item for item in ths} + dc_map = {str(item["identifier"]): item for item in dc} + combined = [] + for identifier in set(ths_map) | set(dc_map): + ths_item = ths_map.get(identifier) + dc_item = dc_map.get(identifier) + base = ths_item or dc_item or {} + ths_rank = int(ths_item["rank"]) if ths_item else None + dc_rank = int(dc_item["rank"]) if dc_item else None + score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25 + combined.append( + { + **base, + "ths_rank": ths_rank, + "dc_rank": dc_rank, + "score": round(score, 2), + "dual_source": bool(ths_item and dc_item), + "concepts": list((ths_item or {}).get("concepts") or []), + } + ) + combined.sort( + key=lambda item: (bool(item["dual_source"]), float(item["score"])), reverse=True + ) + for rank, item in enumerate(combined, start=1): + item["rank"] = rank + return { + "trade_date": trade_date, + "summary": { + "ths_count": len(ths), + "dc_count": len(dc), + "dual_count": sum(bool(item["dual_source"]) for item in combined), + }, + "combined": combined[:200], + "ths": ths, + "dc": dc, + } + + +def _normalize( + rows: tuple[dict[str, Any], ...], + data_type: str, + previous_rows: tuple[dict[str, Any], ...], +) -> list[dict[str, Any]]: + previous = { + str(row.get("ts_code") or ""): int(_number(row.get("rank"))) + for row in previous_rows + if str(row.get("data_type") or "") == data_type + } + items = [] + for row in rows: + if str(row.get("data_type") or "") != data_type: + continue + identifier = str(row.get("ts_code") or "") + rank = int(_number(row.get("rank"))) + if not identifier or rank <= 0: + continue + prior = previous.get(identifier) + items.append( + { + "rank": rank, + "identifier": identifier, + "code": identifier.split(".")[0], + "name": str(row.get("ts_name") or ""), + "change": round(_number(row.get("pct_change")), 2), + "price": round(_number(row.get("current_price")), 2), + "hot": round(_number(row.get("hot")), 1), + "rank_change": prior - rank if prior else None, + "concepts": _concepts(row.get("concept")), + "reason": str(row.get("rank_reason") or ""), + "rank_time": str(row.get("rank_time") or ""), + } + ) + return sorted(items, key=lambda item: int(item["rank"])) + + +def _concepts(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value or "").strip() + if not text: + return [] + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + except json.JSONDecodeError: + pass + return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()] + + +def _number(value: Any) -> float: + try: + number = float(value) + return number if number == number else 0.0 + except (TypeError, ValueError): + return 0.0 diff --git a/next/backend/features/market/insights/service.py b/next/backend/features/market/insights/service.py new file mode 100644 index 0000000..69b949d --- /dev/null +++ b/next/backend/features/market/insights/service.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import json +from datetime import datetime, time +from typing import Any +from zoneinfo import ZoneInfo + +from backend.data.contracts import SnapshotState +from backend.data.gateway import DataGateway, MarketDataUnavailable +from backend.data.providers.base import ProviderError +from backend.data.repository import MarketRepository +from backend.database.connection import Database +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.support import ( + auction_phase as _auction_phase, +) +from backend.features.market.insights.support import ( + clock as _clock, +) +from backend.features.market.insights.support import ( + decorate as _decorate, +) +from backend.features.market.insights.support import ( + empty_auction as _empty_auction, +) +from backend.features.market.insights.support import ( + empty_standard as _empty_standard, +) +from backend.features.market.insights.support import ( + number as _number, +) +from backend.features.market.insights.support import ( + result as _result, +) +from backend.features.market.insights.support import ( + rows as _rows, +) +from backend.features.market.insights.support import ( + serialized_rows as _serialized_rows, +) +from backend.features.market.insights.support import ( + standard as _standard, +) +from backend.features.market.insights.support import ( + tuple_or_none as _tuple_or_none, +) +from backend.features.market.insights.support import ( + valid_date as _date, +) +from backend.features.market.insights.themes import build_theme_detail, build_theme_library + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +class MarketInsightError(RuntimeError): + pass + + +class MarketInsightService: + def __init__( + self, database: Database, repository: MarketRepository, gateway: DataGateway + ) -> None: + self._database = database + self._repository = repository + self._gateway = gateway + + def workspace( + self, + key: str, + requested_date: str | None = None, + *, + user_id: int, + force: bool = False, + now: datetime | None = None, + ) -> dict[str, Any]: + if key == "auction": + return self.auction(requested_date, user_id=user_id, force=force, now=now) + if key == "themes": + return self.themes(requested_date, force=force) + if key == "popularity": + return self.popularity(requested_date, force=force) + if key == "dragon-list": + return self.dragon_list(requested_date, force=force) + raise MarketInsightError("不支持的市场洞察工作区") + + def auction( + self, + requested_date: str | None, + *, + user_id: int, + force: bool = False, + now: datetime | None = None, + ) -> dict[str, Any]: + clock = _clock(now) + requested, trade_date, previous = self._trade_dates(requested_date, clock) + phase = _auction_phase(requested, trade_date, clock) + target = previous if phase == "pending" else trade_date + baseline = self._previous_date(target) + cached = self._snapshot("auction", target) + if cached and not force and phase not in {"observing", "selection"}: + return _decorate( + self._personalize_auction(cached, user_id), + requested, + phase, + carried_forward=target != requested, + message=( + "今日竞价尚未开始,显示前一交易日归档" + if phase == "pending" + else "" + ), + ) + + inputs = self._gateway.insight_inputs("auction", target, baseline) + raw = _rows(inputs.get("auction")) + dynamic = False + observed_at = clock + if phase in {"observing", "selection"}: + identifiers = self._auction_universe(baseline, inputs) + end = time(9, 25) if phase == "selection" else clock.time().replace(tzinfo=None) + try: + live = self._gateway.dynamic_auction( + identifiers, + f"{target} 09:15:00", + f"{target} {end.strftime('%H:%M:%S')}", + ) + raw = live.rows + observed_at = live.metadata.observed_at + dynamic = True + except (MarketDataUnavailable, ProviderError): + if phase == "observing": + prior = self._snapshot("auction", previous) + if prior: + return _decorate( + self._personalize_auction(prior, user_id), + requested, + phase, + carried_forward=True, + message="今日动态竞价暂不可用,当前显示前一交易日归档", + current_available=False, + ) + return _empty_auction( + requested, + previous, + phase, + "今日动态竞价暂不可用,且没有历史归档", + ) + if not raw: + if cached: + return _decorate( + self._personalize_auction(cached, user_id), + requested, + phase, + False, + "当前读取失败,保留真实归档", + ) + return _empty_auction(requested, target, phase, "该交易日暂无可用竞价快照") + + payload = build_auction( + trade_date=target, + raw_rows=raw, + price_limits=_rows(inputs.get("price_limits")), + directory=self._gateway.stock_directory(), + prior_snapshot=self._market_snapshot(baseline), + ths_hot=_rows(inputs.get("ths_hot")), + dc_hot=_rows(inputs.get("dc_hot")), + history=self._auction_history(target), + dynamic=dynamic, + ) + minimum = 0.8 if phase == "observing" else 0.9 + if float(payload["coverage"]) < minimum: + if cached: + return _decorate( + self._personalize_auction(cached, user_id), + requested, + phase, + False, + f"竞价覆盖率不足{minimum * 100:.0f}%,保留原有真实归档", + ) + return _empty_auction( + requested, + target, + phase, + f"竞价覆盖率不足{minimum * 100:.0f}%,未形成正式结果", + ) + state = ( + SnapshotState.REALTIME + if phase == "observing" + else SnapshotState.FINAL + if target == clock.date().isoformat() + else SnapshotState.ARCHIVE + ) + payload["observed_at"] = observed_at.isoformat(timespec="seconds") + payload["state"] = state.value + if phase != "observing": + source = "ifind" if dynamic else "tushare" + self._save("auction", target, "", payload, state, source, payload["coverage"]) + return _decorate( + self._personalize_auction(payload, user_id), + requested, + phase, + target != requested, + "", + ) + + def _personalize_auction( + self, payload: dict[str, Any], user_id: int + ) -> dict[str, Any]: + result = {**payload} + market_rows = list(result.pop("_market_rows", ())) + with self._database.read() as connection: + watchlist = tuple( + dict(row) for row in self._repository.watchlist(connection, user_id) + ) + result["watchlist_rows"] = build_watchlist_rows( + market_rows, + list(result.get("rows") or ()), + list(result.get("one_price_rows") or ()), + watchlist, + ) + result["watchlist_ready"] = bool(market_rows) or not watchlist + return result + + def themes(self, requested_date: str | None, *, force: bool = False) -> dict[str, Any]: + requested, trade_date, _ = self._trade_dates(requested_date) + cached = self._snapshot("themes", trade_date) + if cached and not force: + return _standard(cached, requested) + inputs = self._gateway.insight_inputs("themes", trade_date) + directory = _result(inputs.get("directory")) + daily = _result(inputs.get("daily")) + hot = _result(inputs.get("hot")) + if directory is None: + fallback = self._latest_snapshot("themes", trade_date) + if fallback: + return _standard(fallback, requested, "当前题材目录暂不可用,显示最近有效榜单") + raise MarketInsightError("题材目录暂不可用") + payload = build_theme_library( + trade_date, + directory.rows, + daily.rows if daily else (), + hot.rows if hot else (), + ) + payload["observed_at"] = directory.metadata.observed_at.isoformat(timespec="seconds") + payload["state"] = SnapshotState.ARCHIVE.value + payload["message"] = "" if daily and daily.rows else "该交易日暂无题材行情" + with self._database.transaction() as connection: + self._repository.replace_themes( + connection, + list(payload["items"]), + directory.metadata.source.value, + payload["observed_at"], + ) + self._save("themes", trade_date, "", payload, SnapshotState.ARCHIVE, "tushare", 1) + return _standard(payload, requested) + + def theme_detail(self, identifier: str, requested_date: str | None) -> dict[str, Any]: + library = self.themes(requested_date) + code = identifier.strip().upper() + theme = next((item for item in library["items"] if item["code"] == code), None) + if theme is None: + raise MarketInsightError("未找到该题材") + trade_date = str(library["trade_date"]) + cached = self._snapshot("themes", trade_date, code) + if cached: + return cached + inputs = self._gateway.insight_inputs("theme-detail", trade_date, identifier=code) + members = _result(inputs.get("members")) + daily = _result(inputs.get("daily")) + payload = build_theme_detail( + trade_date, + theme, + members.rows if members else (), + daily.rows if daily else (), + ) + payload["message"] = "" if members and members.rows else "该题材暂无可核验成分股" + payload["observed_at"] = ( + members.metadata.observed_at if members else datetime.now(SHANGHAI) + ).isoformat(timespec="seconds") + payload["state"] = SnapshotState.ARCHIVE.value + if members is not None: + self._save( + "themes", trade_date, code, payload, SnapshotState.ARCHIVE, "tushare", 1 + ) + return payload + + def popularity( + self, requested_date: str | None, *, force: bool = False + ) -> dict[str, Any]: + requested, trade_date, previous = self._trade_dates(requested_date) + cached = self._snapshot("popularity", trade_date) + if cached and not force: + return _standard(cached, requested) + inputs = self._gateway.insight_inputs("popularity", trade_date, previous) + ths = _result(inputs.get("ths")) + dc = _result(inputs.get("dc")) + if not ((ths and ths.rows) or (dc and dc.rows)): + fallback = self._latest_snapshot("popularity", previous) + if fallback: + return _standard(fallback, requested, "当日榜单尚未生成,显示最近有效榜单") + return _empty_standard(requested, trade_date, "该交易日暂无可用人气榜") + payload = build_popularity( + trade_date, + ths.rows if ths else (), + dc.rows if dc else (), + _rows(inputs.get("previous_ths")), + _rows(inputs.get("previous_dc")), + ) + payload["observed_at"] = datetime.now(SHANGHAI).isoformat(timespec="seconds") + payload["state"] = SnapshotState.ARCHIVE.value + missing = [] + if ths is None: + missing.append("同花顺榜单暂不可用") + if dc is None: + missing.append("东方财富榜单暂不可用") + payload["message"] = ";".join(missing) + coverage = (int(ths is not None) + int(dc is not None)) / 2 + self._save( + "popularity", + trade_date, + "", + payload, + SnapshotState.ARCHIVE, + "tushare", + coverage, + ) + return _standard(payload, requested) + + def dragon_list( + self, requested_date: str | None, *, force: bool = False + ) -> dict[str, Any]: + requested, trade_date, previous = self._trade_dates(requested_date) + raw = self._snapshot("dragon-list", trade_date) + if raw is None or force: + inputs = self._gateway.insight_inputs("dragon-list", trade_date) + raw = { + "trade_date": trade_date, + "observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"), + "state": SnapshotState.ARCHIVE.value, + "official": _serialized_rows(inputs.get("official")), + "profiles": _serialized_rows(inputs.get("profiles")), + "stocks": _serialized_rows(inputs.get("stocks")), + "seats": _serialized_rows(inputs.get("seats")), + } + coverage = sum(value is not None for value in raw.values() if isinstance(value, list)) + self._save( + "dragon-list", + trade_date, + "", + raw, + SnapshotState.ARCHIVE, + "tushare", + min(coverage / 4, 1), + ) + with self._database.read() as connection: + aliases = self._repository.seat_aliases(connection) + result = build_dragon_list( + trade_date=trade_date, + official_rows=_tuple_or_none(raw.get("official")), + profile_rows=_tuple_or_none(raw.get("profiles")), + stock_rows=_tuple_or_none(raw.get("stocks")), + seat_rows=_tuple_or_none(raw.get("seats")), + aliases=aliases, + ) + result.update( + { + "requested_date": requested, + "previous_date": previous, + "observed_at": raw.get("observed_at"), + "state": SnapshotState.ARCHIVE.value, + "carried_forward": False, + } + ) + return result + + def save_seat_alias(self, seat_name: str, alias_name: str, user_id: int) -> dict[str, str]: + seat = " ".join(seat_name.split()) + alias = " ".join(alias_name.split()) + if not seat or not alias: + raise MarketInsightError("营业部和游资名称不能为空") + with self._database.transaction() as connection: + self._repository.save_seat_alias( + connection, + seat, + alias, + datetime.now(SHANGHAI).isoformat(timespec="seconds"), + user_id, + ) + return {"seat_name": seat, "alias_name": alias} + + def _trade_dates( + self, requested_date: str | None, clock: datetime | None = None + ) -> tuple[str, str, str]: + try: + requested = _date( + requested_date or (clock or datetime.now(SHANGHAI)).date().isoformat() + ) + except ValueError as exc: + raise MarketInsightError("日期格式无效") from exc + dates = self._gateway.trading_dates(requested, 2) + if len(dates) < 2: + raise MarketInsightError("请先同步完整交易日历") + return requested, dates[0], dates[1] + + def _previous_date(self, trade_date: str) -> str: + dates = self._gateway.trading_dates(trade_date, 2) + if len(dates) < 2: + raise MarketInsightError("缺少前一交易日") + return dates[1] + + def _auction_universe( + self, baseline: str, inputs: dict[str, Any] + ) -> tuple[str, ...]: + snapshot = self._market_snapshot(baseline) + codes = { + str(item.get("identifier") or "") + for key in ("limits", "broken") + for item in snapshot.get(key) or [] + } + for key, data_type in (("ths_hot", "热股"), ("dc_hot", "A股市场")): + for row in _rows(inputs.get(key)): + valid_type = str(row.get("data_type") or "") == data_type + top_twenty = int(_number(row.get("rank"), 9999)) <= 20 + if valid_type and top_twenty: + codes.add(str(row.get("ts_code") or "")) + return tuple(sorted(code for code in codes if code)) + + def _market_snapshot(self, trade_date: str) -> dict[str, Any]: + with self._database.read() as connection: + row = self._repository.latest_summary(connection, trade_date) + if row is None or str(row["trade_date"]) != trade_date: + return {} + return json.loads(str(row["payload_json"])) + + def _auction_history(self, trade_date: str) -> list[dict[str, Any]]: + with self._database.read() as connection: + rows = self._repository.insight_snapshots(connection, "auction", trade_date, 10) + result = [] + for row in rows: + payload = json.loads(str(row["payload_json"])) + summary = payload.get("summary") or {} + result.append( + { + "trade_date": str(row["trade_date"]), + "amount_billion": _number(summary.get("amount_billion")), + "stock_count": int(summary.get("stock_count") or 0), + } + ) + return result + + def _snapshot( + self, kind: str, trade_date: str, entity_key: str = "" + ) -> dict[str, Any] | None: + with self._database.read() as connection: + row = self._repository.insight_snapshot(connection, kind, trade_date, entity_key) + return json.loads(str(row["payload_json"])) if row else None + + def _latest_snapshot( + self, kind: str, through: str, entity_key: str = "" + ) -> dict[str, Any] | None: + with self._database.read() as connection: + row = self._repository.latest_insight_snapshot(connection, kind, through, entity_key) + return json.loads(str(row["payload_json"])) if row else None + + def _save( + self, + kind: str, + trade_date: str, + entity_key: str, + payload: dict[str, Any], + state: SnapshotState, + source: str, + coverage: float, + ) -> None: + with self._database.transaction() as connection: + self._repository.save_insight_snapshot( + connection, + kind=kind, + trade_date=trade_date, + entity_key=entity_key, + observed_at=str( + payload.get("observed_at") + or datetime.now(SHANGHAI).isoformat(timespec="seconds") + ), + state=state.value, + source=source, + coverage=max(0, min(coverage, 1)), + payload=payload, + ) diff --git a/next/backend/features/market/insights/support.py b/next/backend/features/market/insights/support.py new file mode 100644 index 0000000..86d7539 --- /dev/null +++ b/next/backend/features/market/insights/support.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Any +from zoneinfo import ZoneInfo + +from backend.data.contracts import ProviderResult + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def auction_phase(requested: str, trade_date: str, clock: datetime) -> str: + if requested != clock.date().isoformat() or trade_date != clock.date().isoformat(): + return "archive" + local = clock.time().replace(tzinfo=None) + if local < time(9, 15): + return "pending" + if local < time(9, 25): + return "observing" + if local < time(9, 30): + return "selection" + return "finalized" + + +def decorate( + payload: dict[str, Any], + requested: str, + phase: str, + carried_forward: bool, + message: str, + current_available: bool = True, +) -> dict[str, Any]: + return { + **payload, + "requested_date": requested, + "phase": phase, + "carried_forward": carried_forward, + "message": message or str(payload.get("message") or ""), + "current_available": current_available, + } + + +def standard(payload: dict[str, Any], requested: str, message: str = "") -> dict[str, Any]: + trade_date = str(payload.get("trade_date") or "") + return { + **payload, + "requested_date": requested, + "carried_forward": trade_date != requested, + "message": message or str(payload.get("message") or ""), + } + + +def empty_standard(requested: str, trade_date: str, message: str) -> dict[str, Any]: + return { + "requested_date": requested, + "trade_date": trade_date, + "observed_at": None, + "state": None, + "carried_forward": trade_date != requested, + "message": message, + "summary": {}, + "items": [], + "combined": [], + "ths": [], + "dc": [], + } + + +def empty_auction( + requested: str, trade_date: str, phase: str, message: str +) -> dict[str, Any]: + return { + **empty_standard(requested, trade_date, message), + "phase": phase, + "current_available": False, + "expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0}, + "themes": {"carry": [], "new_themes": []}, + "amount_history": [], + "focus_rows": [], + "one_price_rows": [], + "rows": [], + "watchlist_rows": [], + "watchlist_ready": False, + } + + +def result(value: Any) -> ProviderResult | None: + return value if isinstance(value, ProviderResult) else None + + +def rows(value: Any) -> tuple[dict[str, Any], ...]: + provider_result = result(value) + return provider_result.rows if provider_result else () + + +def serialized_rows(value: Any) -> list[dict[str, Any]] | None: + provider_result = result(value) + return [dict(row) for row in provider_result.rows] if provider_result else None + + +def tuple_or_none(value: Any) -> tuple[dict[str, Any], ...] | None: + if value is None: + return None + return tuple(dict(row) for row in value) + + +def valid_date(value: str) -> str: + try: + return date.fromisoformat(value).isoformat() + except ValueError as exc: + raise ValueError("日期格式无效") from exc + + +def clock(value: datetime | None) -> datetime: + current = value or datetime.now(SHANGHAI) + return ( + current.replace(tzinfo=SHANGHAI) + if current.tzinfo is None + else current.astimezone(SHANGHAI) + ) + + +def number(value: Any, default: float = 0.0) -> float: + try: + parsed = float(value) + return parsed if parsed == parsed else default + except (TypeError, ValueError): + return default diff --git a/next/backend/features/market/insights/themes.py b/next/backend/features/market/insights/themes.py new file mode 100644 index 0000000..0c7a4a9 --- /dev/null +++ b/next/backend/features/market/insights/themes.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + + +def build_theme_library( + trade_date: str, + directory_rows: tuple[dict[str, Any], ...], + daily_rows: tuple[dict[str, Any], ...], + hot_rows: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + daily = {str(row.get("ts_code") or ""): row for row in daily_rows} + hot = { + str(row.get("ts_code") or ""): int(_number(row.get("rank"), 9999)) + for row in hot_rows + if str(row.get("data_type") or "") == "概念板块" + } + items = [] + for row in directory_rows: + if str(row.get("type") or "").upper() != "N": + continue + if str(row.get("exchange") or "").upper() != "A": + continue + code = str(row.get("ts_code") or "") + name = str(row.get("name") or "").strip() + if not code or not name: + continue + quote = daily.get(code) + items.append( + { + "code": code, + "name": name, + "member_count": int(_number(row.get("count"))), + "change": round(_number((quote or {}).get("pct_change")), 2) + if quote + else None, + "close": round(_number((quote or {}).get("close")), 3) if quote else None, + "turnover_rate": round(_number((quote or {}).get("turnover_rate")), 2) + if quote + else None, + "hot_rank": hot.get(code), + "has_quote": bool(quote), + } + ) + items.sort( + key=lambda item: ( + bool(item["has_quote"]), + item["hot_rank"] is not None, + -(item["hot_rank"] or 9999), + _number(item["change"], -999), + ), + reverse=True, + ) + quoted = [item for item in items if item["has_quote"]] + return { + "trade_date": trade_date, + "summary": { + "theme_count": len(items), + "quoted_count": len(quoted), + "up_count": sum(_number(item["change"]) > 0 for item in quoted), + "down_count": sum(_number(item["change"]) < 0 for item in quoted), + "hot_count": len(hot), + }, + "items": items, + } + + +def build_theme_detail( + trade_date: str, + theme: dict[str, Any], + member_rows: tuple[dict[str, Any], ...], + daily_rows: tuple[dict[str, Any], ...], +) -> dict[str, Any]: + daily = {str(row.get("ts_code") or ""): row for row in daily_rows} + members = [] + seen = set() + for row in member_rows: + identifier = str(row.get("con_code") or "") + if not identifier or identifier in seen: + continue + seen.add(identifier) + quote = daily.get(identifier) + members.append( + { + "identifier": identifier, + "code": identifier.split(".")[0], + "name": str(row.get("con_name") or ""), + "change": round(_number((quote or {}).get("pct_chg")), 2) + if quote + else None, + "close": round(_number((quote or {}).get("close")), 2) if quote else None, + "amount": _number((quote or {}).get("amount")) * 1000 if quote else None, + "quoted": bool(quote), + } + ) + members.sort( + key=lambda item: ( + bool(item["quoted"]), + _number(item["change"], -999), + _number(item["amount"]), + ), + reverse=True, + ) + quoted = [item for item in members if item["quoted"]] + return { + "trade_date": trade_date, + "theme": theme, + "summary": { + "member_count": len(members), + "quoted_count": len(quoted), + "up_count": sum(_number(item["change"]) > 0 for item in quoted), + "down_count": sum(_number(item["change"]) < 0 for item in quoted), + "turnover_rate": theme.get("turnover_rate"), + }, + "members": members, + } + + +def _number(value: Any, default: float = 0.0) -> float: + try: + number = float(value) + return number if number == number else default + except (TypeError, ValueError): + return default diff --git a/next/backend/features/market/routes.py b/next/backend/features/market/routes.py index a6dabae..509ed1f 100644 --- a/next/backend/features/market/routes.py +++ b/next/backend/features/market/routes.py @@ -7,12 +7,16 @@ from fastapi import APIRouter, Path, Query, Request from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal from backend.features.market.schemas import ( ChartResponse, + MarketInsightResponse, MarketSummaryResponse, MarketWorkspaceResponse, ReferenceSyncResponse, RotationMembersResponse, SearchResponse, + SeatAliasRequest, + SeatAliasResponse, SnapshotSyncResponse, + ThemeDetailResponse, TradeContextResponse, ) @@ -95,3 +99,52 @@ def rotation_members( requested_date: Annotated[str | None, Query(alias="date")] = None, ) -> dict: return request.app.state.container.market.rotation_members(sector_name, requested_date) + + +@router.get("/insights/{key}", response_model=MarketInsightResponse) +def insight( + request: Request, + principal: AuthenticatedPrincipal, + key: Annotated[ + Literal["auction", "themes", "popularity", "dragon-list"], Path() + ], + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.insight( + key, requested_date, principal.user.id + ) + + +@router.post("/insights/{key}/sync", response_model=MarketInsightResponse) +def sync_insight( + request: Request, + principal: AdminWritePrincipal, + key: Annotated[ + Literal["auction", "themes", "popularity", "dragon-list"], Path() + ], + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.sync_insight( + key, requested_date, principal.user.id + ) + + +@router.get("/themes/{identifier}", response_model=ThemeDetailResponse) +def theme_detail( + request: Request, + _principal: AuthenticatedPrincipal, + identifier: Annotated[str, Path(min_length=1, max_length=40)], + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.theme_detail(identifier, requested_date) + + +@router.put("/seat-aliases", response_model=SeatAliasResponse) +def save_seat_alias( + payload: SeatAliasRequest, + request: Request, + principal: AdminWritePrincipal, +) -> dict[str, str]: + return request.app.state.container.market.save_seat_alias( + payload.seat_name, payload.alias_name, principal.user.id + ) diff --git a/next/backend/features/market/schemas.py b/next/backend/features/market/schemas.py index d38de9a..89e7f48 100644 --- a/next/backend/features/market/schemas.py +++ b/next/backend/features/market/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class TradeContextResponse(BaseModel): @@ -101,3 +101,37 @@ class RotationMembersResponse(BaseModel): quoted_count: int = Field(ge=0) coverage: float = Field(ge=0, le=1) items: list[dict[str, Any]] = Field(default_factory=list) + + +class MarketInsightResponse(BaseModel): + model_config = ConfigDict(extra="allow") + + requested_date: str + trade_date: str + observed_at: datetime | None = None + state: str | None = None + carried_forward: bool = False + message: str = "" + summary: dict[str, Any] = Field(default_factory=dict) + + +class ThemeDetailResponse(BaseModel): + model_config = ConfigDict(extra="allow") + + trade_date: str + observed_at: datetime | None = None + state: str | None = None + message: str = "" + theme: dict[str, Any] + summary: dict[str, Any] + members: list[dict[str, Any]] + + +class SeatAliasRequest(BaseModel): + seat_name: str = Field(min_length=1, max_length=200) + alias_name: str = Field(min_length=1, max_length=80) + + +class SeatAliasResponse(BaseModel): + seat_name: str + alias_name: str diff --git a/next/backend/features/market/service.py b/next/backend/features/market/service.py index 2ea64cd..ae17fdf 100644 --- a/next/backend/features/market/service.py +++ b/next/backend/features/market/service.py @@ -5,14 +5,22 @@ from typing import Any from backend.data.gateway import DataGateway, MarketDataUnavailable from backend.data.providers.base import ProviderError from backend.data.quality import DataQualityError +from backend.features.market.insights import MarketInsightService +from backend.features.market.insights.service import MarketInsightError from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError from backend.http.errors import AppError class MarketService: - def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None: + def __init__( + self, + gateway: DataGateway, + snapshots: MarketSnapshotService, + insights: MarketInsightService, + ) -> None: self._gateway = gateway self._snapshots = snapshots + self._insights = insights def context(self, requested_date: str | None = None) -> dict[str, Any]: context = self._call(self._gateway.trade_context, requested_date) @@ -94,11 +102,37 @@ class MarketService: self._gateway.sector_members, trade_date, sector_name, representative ) + def insight( + self, key: str, requested_date: str | None, user_id: int + ) -> dict[str, Any]: + return self._call(self._insights.workspace, key, requested_date, user_id=user_id) + + def sync_insight( + self, key: str, requested_date: str | None, user_id: int + ) -> dict[str, Any]: + return self._call( + self._insights.workspace, key, requested_date, user_id=user_id, force=True + ) + + def theme_detail(self, identifier: str, requested_date: str | None = None) -> dict[str, Any]: + return self._call(self._insights.theme_detail, identifier, requested_date) + + def save_seat_alias( + self, seat_name: str, alias_name: str, user_id: int + ) -> dict[str, str]: + return self._call(self._insights.save_seat_alias, seat_name, alias_name, user_id) + @staticmethod - def _call(function, *args): + def _call(function, *args, **kwargs): try: - return function(*args) - except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc: + return function(*args, **kwargs) + except ( + MarketDataUnavailable, + ProviderError, + DataQualityError, + SnapshotSyncError, + MarketInsightError, + ) as exc: raise AppError("market_data_unavailable", str(exc), 503) from exc diff --git a/next/docs/evidence/stage-8/README.md b/next/docs/evidence/stage-8/README.md new file mode 100644 index 0000000..20b9a98 --- /dev/null +++ b/next/docs/evidence/stage-8/README.md @@ -0,0 +1,54 @@ +# 阶段 8 验收记录 + +## 交付范围 + +- 集合竞价:盘前归档、9:15 至 9:25 动态观察、9:25 选定和盘后归档状态;重点异动、我的自选、全部候选、竞价一字四组数据及成交额多日对比。 +- 题材库:题材排名、行情状态、成分股明细和题材悬浮日 K/分时预览;行情缺失与成分股缺失分别表达。 +- 人气热榜:同花顺、东方财富和双榜共识独立呈现;单一来源不会伪装成双榜共识。 +- 龙虎榜:官方上榜数、游资操作明细、未识别席位和游资档案;档案采用左侧列表与右侧详情,不打开长弹窗。 + +## 数据与生命周期 + +- 外部行情仍只通过 `DataGateway` 进入业务层。正式计算使用 iFinD 动态竞价和 Tushare 权威归档数据,公开网页源不进入确定性计算。 +- 动态竞价不可用时不会把前一交易日归档伪装成当日实时结果;携带旧归档时明确标记日期、状态和说明。 +- 竞价一字板使用供应商给出的当日真实涨停价判定,因此同时覆盖新规 ST 10% 涨跌幅,不在业务代码硬编码 5%。 +- 竞价成交额摘要与多日图形使用同一个标准化股票集合,避免同日数字口径不一致。 +- 龙虎榜区分无上榜、明细缺失、席位全部未识别、请求不可用和部分/完整识别状态,不用统一空态掩盖数据链路问题。 +- 席位别名持久化后直接重算已归档操作,不重新请求外部数据。 + +## 账号边界 + +- 竞价市场快照不保存任何用户私有字段。 +- “我的自选”在响应时依据当前已认证账号单独读取并合成,两个账号无法读取彼此的自选记录。 +- 自选股表由独立的版本 6 Migration 建立,避免修改已经执行的历史迁移。 + +## 自动验收 + +- Ruff:通过。 +- Pytest:58 项通过,其中包含竞价生命周期、口径、四类龙虎榜状态、Migration 前进/回退和自选股跨账号隔离。 +- Vue TypeScript 检查:通过。 +- Vitest:2 个文件、5 项通过。 +- Vite 生产构建:通过。 +- Playwright:6 项通过,覆盖阶段 4 至 8 的完整回归、日间/夜间、1920x1080 和 390x844 视口。 +- 浏览器控制台无未处理错误,390px 页面无横向溢出。 +- 敏感值扫描:已提供账号密码和令牌未进入 `next/`。 +- `git diff --check`:通过。 + +## 视觉证据 + +- `auction-light-1920x1080.jpg`:集合竞价日间桌面视图。 +- `popularity-dark-1920x1080.jpg`:人气热榜夜间桌面视图。 +- `dragon-profiles-dark-390x844.jpg`:游资档案夜间移动视图。 + +## 减法证据 + +- 四个页面共用唯一市场洞察服务、统一表格列定义和现有行情预览组件,没有复制页面专属 API 客户端或图表实现。 +- 竞价共享归档与用户自选在服务端边界合成,不建立每用户一份重复市场快照。 +- 新增页面最长 108 行,页面样式 168 行;后端最长纯计算模块 492 行,均在章程门禁内。 +- 没有复制旧系统巨型文件、供应商客户端、通用 CRUD、通用缓存或兼容层。 + +## 剩余边界 + +- 阶段 9 才迁移智能选股、36 套策略、自定义选股和持续跟踪,本阶段没有预建空策略框架。 +- 外部数据是否可用仍取决于管理员凭据和供应商权限;缺少权威数据时保持失败关闭,不生成模拟结果。 +- NAS 生产容器保持不变,最终切换仍需人工明确确认。 diff --git a/next/docs/evidence/stage-8/auction-light-1920x1080.jpg b/next/docs/evidence/stage-8/auction-light-1920x1080.jpg new file mode 100644 index 0000000..5400f53 Binary files /dev/null and b/next/docs/evidence/stage-8/auction-light-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-8/dragon-profiles-dark-390x844.jpg b/next/docs/evidence/stage-8/dragon-profiles-dark-390x844.jpg new file mode 100644 index 0000000..8be06ca Binary files /dev/null and b/next/docs/evidence/stage-8/dragon-profiles-dark-390x844.jpg differ diff --git a/next/docs/evidence/stage-8/popularity-dark-1920x1080.jpg b/next/docs/evidence/stage-8/popularity-dark-1920x1080.jpg new file mode 100644 index 0000000..c386d6d Binary files /dev/null and b/next/docs/evidence/stage-8/popularity-dark-1920x1080.jpg differ diff --git a/next/frontend/src/app/views/WorkspaceView.vue b/next/frontend/src/app/views/WorkspaceView.vue index 7dbad00..d397e20 100644 --- a/next/frontend/src/app/views/WorkspaceView.vue +++ b/next/frontend/src/app/views/WorkspaceView.vue @@ -18,7 +18,10 @@ const locked = computed( () => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access, ); const implementedMarket = computed(() => - ["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes( + [ + "emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", + "rotation", "auction", "themes", "popularity", "dragon-list", + ].includes( workspace.value.key, ), ); diff --git a/next/frontend/src/main.ts b/next/frontend/src/main.ts index 5c986c6..a3e0f0d 100644 --- a/next/frontend/src/main.ts +++ b/next/frontend/src/main.ts @@ -12,6 +12,7 @@ import "./shared/styles/auth.css"; import "./shared/styles/account.css"; import "./shared/styles/market.css"; import "./shared/styles/market-workspace.css"; +import "./shared/styles/market-insights.css"; import "./shared/styles/system.css"; import "./shared/styles/mobile.css"; diff --git a/next/frontend/src/pages/market/MarketWorkspaceView.vue b/next/frontend/src/pages/market/MarketWorkspaceView.vue index 9e0f185..bd79b75 100644 --- a/next/frontend/src/pages/market/MarketWorkspaceView.vue +++ b/next/frontend/src/pages/market/MarketWorkspaceView.vue @@ -1,18 +1,23 @@ + + diff --git a/next/frontend/src/pages/market/insights/DragonPage.vue b/next/frontend/src/pages/market/insights/DragonPage.vue new file mode 100644 index 0000000..1884b36 --- /dev/null +++ b/next/frontend/src/pages/market/insights/DragonPage.vue @@ -0,0 +1,110 @@ + + + diff --git a/next/frontend/src/pages/market/insights/PopularityPage.vue b/next/frontend/src/pages/market/insights/PopularityPage.vue new file mode 100644 index 0000000..3b56019 --- /dev/null +++ b/next/frontend/src/pages/market/insights/PopularityPage.vue @@ -0,0 +1,66 @@ + + + diff --git a/next/frontend/src/pages/market/insights/ThemesPage.vue b/next/frontend/src/pages/market/insights/ThemesPage.vue new file mode 100644 index 0000000..0d37519 --- /dev/null +++ b/next/frontend/src/pages/market/insights/ThemesPage.vue @@ -0,0 +1,109 @@ + + + diff --git a/next/frontend/src/shared/api/market.ts b/next/frontend/src/shared/api/market.ts index bf35c4c..8c546f3 100644 --- a/next/frontend/src/shared/api/market.ts +++ b/next/frontend/src/shared/api/market.ts @@ -77,6 +77,41 @@ export type RotationMembersData = { items: Record[]; }; +export type MarketInsightData = MarketWorkspaceData & { + requested_date: string; + previous_date?: string; + phase?: "pending" | "observing" | "selection" | "finalized" | "archive"; + current_available?: boolean; + coverage?: number; + summary: Record; + expectations?: Record; + themes?: { carry: Record[]; new_themes: Record[] }; + amount_history?: Record[]; + focus_rows?: Record[]; + rows?: Record[]; + one_price_rows?: Record[]; + watchlist_rows?: Record[]; + watchlist_ready?: boolean; + combined?: Record[]; + ths?: Record[]; + dc?: Record[]; + status?: string; + traders?: Record[]; + operations?: Record[]; + unclassified_seats?: Record[]; + profiles?: Record[]; +}; + +export type ThemeDetailData = { + trade_date: string; + observed_at?: string; + state?: string; + message: string; + theme: Record; + summary: Record; + members: Record[]; +}; + export const marketApi = { summary(date?: string): Promise { const query = date ? `?date=${encodeURIComponent(date)}` : ""; @@ -103,4 +138,22 @@ export const marketApi = { `/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`, ); }, + insight(key: string, date: string): Promise { + return api.get( + `/market/insights/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`, + ); + }, + syncInsight(key: string, date: string): Promise { + return api.post( + `/market/insights/${encodeURIComponent(key)}/sync?date=${encodeURIComponent(date)}`, + ); + }, + themeDetail(identifier: string, date: string): Promise { + return api.get( + `/market/themes/${encodeURIComponent(identifier)}?date=${encodeURIComponent(date)}`, + ); + }, + saveSeatAlias(seat_name: string, alias_name: string): Promise<{ seat_name: string; alias_name: string }> { + return api.put("/market/seat-aliases", { seat_name, alias_name }); + }, }; diff --git a/next/frontend/src/shared/market/table.ts b/next/frontend/src/shared/market/table.ts new file mode 100644 index 0000000..9ce24b9 --- /dev/null +++ b/next/frontend/src/shared/market/table.ts @@ -0,0 +1,43 @@ +export type SortDirection = "asc" | "desc"; + +export function sortRows( + rows: Record[], + key: string, + direction: SortDirection, +): Record[] { + const multiplier = direction === "asc" ? 1 : -1; + return [...rows].sort((left, right) => compare(left[key], right[key]) * multiplier); +} + +export function formatNumber(value: unknown, digits = 2): string { + if (value === null || value === undefined || value === "") return ""; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed.toFixed(digits) : ""; +} + +export function formatAmount(value: unknown): string { + if (value === null || value === undefined || value === "") return ""; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return ""; + return parsed >= 100_000_000 + ? `${(parsed / 100_000_000).toFixed(2)} 亿` + : `${(parsed / 10_000).toFixed(2)} 万`; +} + +export function exportCsv(filename: string, rows: string[][]): void { + const content = rows + .map((row) => row.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")) + .join("\r\n"); + const link = document.createElement("a"); + link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" })); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +function compare(left: unknown, right: unknown): number { + const a = Number(left); + const b = Number(right); + if (Number.isFinite(a) && Number.isFinite(b)) return a - b; + return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN"); +} diff --git a/next/frontend/src/shared/styles/market-insights.css b/next/frontend/src/shared/styles/market-insights.css new file mode 100644 index 0000000..0aa494f --- /dev/null +++ b/next/frontend/src/shared/styles/market-insights.css @@ -0,0 +1,187 @@ +.search-control { + min-height: var(--s-32); + display: flex; + align-items: center; + gap: var(--s-8); + padding: 0 var(--s-10); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text-secondary); + background: var(--color-surface); + font-size: var(--font-11); +} + +.search-control input { + min-width: 0; + flex: 1; + border: 0; + color: var(--color-text); + background: var(--c-transparent); +} + +.search-control input::placeholder { color: var(--color-text-faint); } + +.auction-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--s-360); + gap: var(--layout-gap); + align-items: start; +} + +.auction-main, +.popularity-table-card, +.theme-detail-card, +.dragon-operations { + min-width: 0; + overflow: hidden; +} + +.auction-dataset-bar { + min-height: var(--s-46); + display: flex; + align-items: stretch; + border-bottom: var(--s-1) solid var(--color-divider); +} + +.auction-datasets { + align-self: center; + margin-left: var(--s-10); +} + +.auction-datasets b { margin-left: var(--s-4); font-variant-numeric: tabular-nums; } + +.auction-inline-summary { + min-width: var(--s-360); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0 0 0 auto; + border-left: var(--s-1) solid var(--color-divider); +} + +.auction-inline-summary div { + display: grid; + align-content: center; + gap: var(--s-2); + padding: var(--s-6) var(--s-8); + border-right: var(--s-1) solid var(--color-divider); + text-align: right; +} + +.auction-inline-summary div:last-child { border-right: 0; } +.auction-inline-summary dt { color: var(--color-text-faint); font-size: var(--font-10-5); } +.auction-inline-summary dd { margin: 0; font-size: var(--font-12); font-weight: var(--weight-700); font-variant-numeric: tabular-nums; } + +.auction-toolbar, +.popularity-toolbar, +.dragon-toolbar { + min-height: var(--s-46); + display: flex; + align-items: center; + gap: var(--s-8); + padding: var(--s-7) var(--s-10); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.auction-toolbar .seg-control, +.popularity-toolbar .seg-control, +.dragon-toolbar .seg-control { margin-left: 0; } +.auction-toolbar .search-control, +.popularity-toolbar .search-control, +.dragon-toolbar .search-control { width: var(--s-260); margin-left: auto; } + +.auction-side { display: grid; gap: var(--layout-gap); } +.auction-evidence-list { display: grid; gap: var(--s-1); padding: var(--s-6) var(--s-14) var(--s-10); } +.auction-evidence-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-2) var(--s-8); padding: var(--s-7) 0; border-bottom: var(--s-1) solid var(--color-divider); } +.auction-evidence-list > div:last-of-type { border-bottom: 0; } +.auction-evidence-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auction-evidence-list span { color: var(--color-primary); font-size: var(--font-11); } +.auction-evidence-list small { grid-column: 1 / -1; color: var(--color-text-secondary); font-size: var(--font-10-5); } +.auction-evidence-list > p { padding: var(--s-14) 0; text-align: center; } + +.auction-bars { display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); } +.auction-bars > div { display: grid; grid-template-columns: var(--s-44) minmax(0, 1fr) var(--s-44); align-items: center; gap: var(--s-8); font-size: var(--font-10-5); } +.auction-bars i { height: var(--s-7); overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); } +.auction-bars b { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); } +.auction-bars strong { text-align: right; font-variant-numeric: tabular-nums; } + +.themes-layout { + display: grid; + grid-template-columns: var(--s-320) minmax(0, 1fr); + gap: var(--layout-gap); + align-items: start; +} + +.theme-directory { position: relative; min-width: 0; } +.theme-search { margin: var(--s-10); } +.theme-rank-list { max-height: calc(100vh - var(--s-260)); overflow-y: auto; border-top: var(--s-1) solid var(--color-divider); } +.theme-rank-list > button { width: 100%; display: grid; grid-template-columns: var(--s-28) minmax(0, 1fr) var(--s-56); align-items: center; gap: var(--s-8); padding: var(--s-8) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; } +.theme-rank-list > button:hover, +.theme-rank-list > button.active { background: var(--color-primary-soft); } +.theme-rank-list > button > b { color: var(--color-text-faint); text-align: center; font-variant-numeric: tabular-nums; } +.theme-rank-list > button:nth-child(-n+3) > b { color: var(--color-warning); font-size: var(--font-14); } +.theme-rank-list > button > span { min-width: 0; display: grid; gap: var(--s-2); } +.theme-rank-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.theme-rank-list small { color: var(--color-text-faint); font-size: var(--font-10-5); } +.theme-rank-list em { font-style: normal; text-align: right; font-size: var(--font-11); font-variant-numeric: tabular-nums; } +.theme-preview-popover { position: absolute; top: var(--s-64); left: calc(100% + var(--layout-gap)); z-index: var(--z-popover); width: var(--s-400); overflow: hidden; border: var(--s-1) solid var(--color-border); border-radius: var(--card-radius); background: var(--color-surface-raised); box-shadow: var(--shadow-float); } +.theme-summary-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: var(--s-1) solid var(--color-divider); } +.theme-summary-strip > div { display: grid; gap: var(--s-4); padding: var(--s-10) var(--s-14); border-right: var(--s-1) solid var(--color-divider); } +.theme-summary-strip > div:last-child { border-right: 0; } +.theme-summary-strip span { color: var(--color-text-secondary); font-size: var(--font-11); } +.theme-summary-strip strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; } + +.popularity-summary-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + overflow: hidden; + border: var(--s-1) solid var(--color-border); + border-radius: var(--card-radius); + background: var(--color-canvas); +} + +.popularity-summary-strip article { min-width: 0; display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); border-right: var(--s-1) solid var(--color-border); box-shadow: none; } +.popularity-summary-strip article:nth-child(1) { background: var(--color-primary-soft); } +.popularity-summary-strip article:nth-child(2) { background: var(--color-up-soft); } +.popularity-summary-strip article:nth-child(3) { border-right: 0; background: var(--color-warning-soft); } +.popularity-summary-strip header { display: flex; justify-content: space-between; gap: var(--s-8); } +.popularity-summary-strip header span { color: var(--color-text-secondary); } +.popularity-summary-strip header strong { font-variant-numeric: tabular-nums; } +.popularity-summary-strip p { overflow: hidden; color: var(--color-text); font-size: var(--font-12); text-overflow: ellipsis; white-space: nowrap; } + +.dragon-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--layout-gap); } +.dragon-summary-grid article { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-8); padding: var(--s-12) var(--s-14); } +.dragon-summary-grid span { color: var(--color-text-secondary); font-size: var(--font-11); } +.dragon-summary-grid strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; } +.dragon-daily-layout { display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); gap: var(--layout-gap); align-items: start; } +.dragon-traders { min-width: 0; overflow: hidden; } +.dragon-traders .card-header .btn { margin-left: auto; } +.dragon-trader-list { display: grid; } +.dragon-trader-list > button { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-8); padding: var(--s-9) var(--s-12); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; } +.dragon-trader-list > button:hover, +.dragon-trader-list > button.active { background: var(--color-primary-soft); } +.dragon-trader-list > button span { min-width: 0; display: grid; gap: var(--s-2); } +.dragon-trader-list small { overflow: hidden; color: var(--color-text-secondary); font-size: var(--font-10-5); text-overflow: ellipsis; white-space: nowrap; } +.dragon-trader-list b { align-self: center; font-variant-numeric: tabular-nums; } +.dragon-trader-list > p { padding: var(--s-20); text-align: center; } +.dragon-pending { border-top: var(--s-1) solid var(--color-divider); } +.dragon-pending > header { display: flex; justify-content: space-between; padding: var(--s-10) var(--s-12); background: var(--color-warning-soft); } +.dragon-pending > div { display: grid; gap: var(--s-6); padding: var(--s-8) var(--s-12); border-top: var(--s-1) solid var(--color-divider); } +.dragon-pending p, +.dragon-pending form { display: flex; align-items: center; gap: var(--s-8); } +.dragon-pending p span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dragon-pending input { min-width: 0; flex: 1; min-height: var(--s-32); padding: 0 var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface); } +.dragon-table-scroll { height: var(--s-400); overflow: auto; } +.dragon-table-scroll .data-table-wrap { overflow: visible; } +.dragon-profiles { min-height: var(--s-400); display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); overflow: hidden; } +.dragon-profiles > aside { border-right: var(--s-1) solid var(--color-divider); } +.dragon-profiles > aside > div { max-height: var(--s-400); overflow-y: auto; } +.dragon-profiles > aside button { width: 100%; display: grid; gap: var(--s-2); padding: var(--s-9) var(--s-14); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; } +.dragon-profiles > aside button.active, +.dragon-profiles > aside button:hover { background: var(--color-primary-soft); } +.dragon-profiles > aside small { color: var(--color-text-secondary); font-size: var(--font-10-5); } +.dragon-profiles > article { display: grid; align-content: start; gap: var(--s-20); padding: var(--s-20); } +.dragon-profiles > article > header { display: flex; justify-content: space-between; gap: var(--s-14); } +.dragon-profiles > article > header p { margin-top: var(--s-8); color: var(--color-text-secondary); line-height: var(--s-20); } +.dragon-profiles h3 { margin: 0 0 var(--s-8); font-size: var(--font-13); } +.dragon-profiles ul { display: flex; flex-wrap: wrap; gap: var(--s-8); margin: 0; padding: 0; list-style: none; } +.dragon-profiles li { padding: var(--s-6) var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface-muted); font-size: var(--font-11); } diff --git a/next/frontend/src/shared/styles/mobile.css b/next/frontend/src/shared/styles/mobile.css index 67511c9..1a1324c 100644 --- a/next/frontend/src/shared/styles/mobile.css +++ b/next/frontend/src/shared/styles/mobile.css @@ -245,3 +245,92 @@ border-bottom: var(--s-1) solid var(--color-divider); } } + +@media (max-width: 1399px) { + .auction-layout { + grid-template-columns: minmax(0, 1fr); + } + + .auction-side { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .auction-dataset-bar { + align-items: stretch; + flex-direction: column; + } + + .auction-datasets { + align-self: stretch; + overflow-x: auto; + margin: var(--s-7) var(--s-10); + } + + .auction-inline-summary { + width: 100%; + min-width: 0; + margin-left: 0; + border-top: var(--s-1) solid var(--color-divider); + border-left: 0; + } +} + +@media (max-width: 1023px) { + .auction-side, + .themes-layout, + .popularity-summary-strip, + .dragon-summary-grid, + .dragon-daily-layout, + .dragon-profiles { + grid-template-columns: minmax(0, 1fr); + } + + .auction-toolbar, + .popularity-toolbar, + .dragon-toolbar { + align-items: stretch; + flex-direction: column; + } + + .auction-toolbar .seg-control, + .popularity-toolbar .seg-control, + .dragon-toolbar .seg-control { + width: 100%; + overflow-x: auto; + } + + .auction-toolbar .search-control, + .popularity-toolbar .search-control, + .dragon-toolbar .search-control { + width: 100%; + margin-left: 0; + } + + .auction-inline-summary, + .theme-summary-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .theme-rank-list { + max-height: var(--s-320); + } + + .theme-preview-popover { + display: none; + } + + .popularity-summary-strip article, + .dragon-profiles > aside { + border-right: 0; + border-bottom: var(--s-1) solid var(--color-divider); + } + + .dragon-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dragon-table-scroll { + height: auto; + overflow: visible; + } +} diff --git a/next/tests/e2e/stage8.spec.js b/next/tests/e2e/stage8.spec.js new file mode 100644 index 0000000..28e9818 --- /dev/null +++ b/next/tests/e2e/stage8.spec.js @@ -0,0 +1,144 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { expect, test } = require("@playwright/test"); + +const evidence = path.resolve(__dirname, "../../docs/evidence/stage-8"); +test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true })); + +async function authenticate(page) { + await page.goto("/"); + await page.getByLabel("账号名").fill("stage8admin"); + await page.getByLabel("密码").fill("Stage8-pass-123!"); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await expect(page.locator(".sidebar, .field-error")).toBeVisible(); + if (!(await page.locator(".sidebar").isVisible())) { + await page.getByRole("tab", { name: "注册" }).click(); + await page.getByRole("button", { name: "注册并登录" }).click(); + } +} + +const summary = { + context: { + requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29", + observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "", + }, + values: { up_count: 2800, down_count: 2100, limit_up: 58, limit_down: 5, broken: 20, seal_rate: 74.4, amount: 1500000000000, temperature: 48 }, +}; + +const candidate = (code, name, expectation, score) => ({ + identifier: `${code}.SZ`, code, name, sector: "机器人", source_label: "昨日涨停 · 同花顺热榜", + expectation, change: expectation === "超预期" ? 6.8 : 2.1, expected_change: 4, + attention_score: score, volume_ratio: 1.8, amount_million: 22.5, is_market_core: score > 80, +}); +const auction = { + requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T09:25:00+08:00", + state: "final", carried_forward: false, message: "", phase: "finalized", current_available: true, coverage: 0.98, + summary: { stock_count: 5200, focus_count: 2, one_price_count: 1, amount_billion: 42.03, amount_change_previous: -12.4 }, + expectations: { 超预期: 1, 符合预期: 1, 低于预期: 0 }, + focus_rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)], + rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)], + one_price_rows: [{ ...candidate("000003", "国华网安", "", 0), attention_score: null, expectation: "" }], + watchlist_rows: [], watchlist_ready: false, + themes: { carry: [{ name: "机器人", status: "强承接", matched_count: 6, median_change: 4.2 }], new_themes: [{ name: "算力租赁", stock_count: 3, leaders: ["平安银行", "万科A"] }] }, + amount_history: [ + { trade_date: "2026-07-28", amount_billion: 36.2, stock_count: 5180 }, + { trade_date: "2026-07-29", amount_billion: 47.8, stock_count: 5190 }, + { trade_date: "2026-07-30", amount_billion: 42.03, stock_count: 5200 }, + ], +}; +const themes = { + requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", + state: "archive", carried_forward: false, message: "", summary: { theme_count: 3, quoted_count: 3, up_count: 2, down_count: 1 }, + items: [ + { code: "885001.TI", name: "机器人", member_count: 120, change: 3.2, turnover_rate: 4.8, hot_rank: 1, has_quote: true }, + { code: "885002.TI", name: "算力租赁", member_count: 82, change: 1.5, turnover_rate: 3.2, hot_rank: 2, has_quote: true }, + { code: "885003.TI", name: "保险", member_count: 18, change: -0.8, turnover_rate: 1.1, hot_rank: 8, has_quote: true }, + ], +}; +const themeDetail = { + trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", state: "archive", message: "", + theme: themes.items[0], summary: { member_count: 3, quoted_count: 2, up_count: 2, down_count: 0, turnover_rate: 4.8 }, + members: [ + { identifier: "000001.SZ", code: "000001", name: "平安银行", change: 3.2, close: 12.3, amount: 500000000, quoted: true }, + { identifier: "000002.SZ", code: "000002", name: "万科A", change: 1.1, close: 8.4, amount: 220000000, quoted: true }, + { identifier: "000003.SZ", code: "000003", name: "停牌样本", change: null, close: null, amount: null, quoted: false }, + ], +}; +const hotRow = (rank, code, name, dual = true) => ({ rank, identifier: `${code}.SZ`, code, name, change: 2.4, price: 12.2, ths_rank: rank, dc_rank: dual ? rank + 1 : null, dual_source: dual, rank_change: 2, concepts: ["机器人"], reason: "市场关注度上升" }); +const popularity = { + requested_date: "2026-07-30", trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", + state: "archive", carried_forward: true, message: "当日榜单尚未生成,显示最近有效榜单", + summary: { ths_count: 3, dc_count: 3, dual_count: 2 }, + combined: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A"), hotRow(3, "000003", "国华网安", false)], + ths: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A")], + dc: [hotRow(1, "000002", "万科A"), hotRow(2, "000001", "平安银行")], +}; +const dragon = { + requested_date: "2026-07-30", trade_date: "2026-07-30", previous_date: "2026-07-29", + observed_at: "2026-07-30T18:00:00+08:00", state: "archive", carried_forward: false, + status: "partial", message: "部分营业部尚未归类", + summary: { official_stock_count: 76, trader_count: 2, operation_count: 3, unclassified_count: 1, net_million: 38.5 }, + traders: [ + { name: "章盟主", description: "偏好主线核心与大成交标的", net_million: 28.5, operation_count: 2 }, + { name: "作手新一", description: "重视情绪拐点与辨识度", net_million: 12.2, operation_count: 1 }, + ], + operations: [ + { identifier: "000001.SZ", code: "000001", name: "平安银行", direction: "买入", buy_million: 30, sell_million: 5, net_million: 25, trader_name: "章盟主", seat_name: "国泰君安上海江苏路", reason: "日涨幅偏离值达7%", recognized: true }, + { identifier: "000002.SZ", code: "000002", name: "万科A", direction: "买入", buy_million: 15, sell_million: 2.8, net_million: 12.2, trader_name: "作手新一", seat_name: "国泰君安南京太平南路", reason: "日振幅值达15%", recognized: true }, + { identifier: "000003.SZ", code: "000003", name: "国华网安", direction: "买入", buy_million: 4.1, sell_million: 2.8, net_million: 1.3, trader_name: "", seat_name: "测试待归类营业部", reason: "连续三个交易日涨幅偏离", recognized: false }, + ], + unclassified_seats: [{ seat_name: "测试待归类营业部", net_million: 1.3, operation_count: 1 }], + profiles: [ + { name: "章盟主", description: "偏好主线核心与大成交标的", organization_count: 2, organizations: ["国泰君安上海江苏路", "国泰君安上海新闸路"] }, + { name: "作手新一", description: "重视情绪拐点与辨识度", organization_count: 1, organizations: ["国泰君安南京太平南路"] }, + ], +}; + +async function mockStage8(page) { + await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary) })); + await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", carried_forward: false, message: "", overview: summary.values, sentiment: {}, history: [] }) })); + await page.route("**/api/market/insights/*", (route) => { + const key = new URL(route.request().url()).pathname.split("/").pop(); + const payload = key === "auction" ? auction : key === "themes" ? themes : key === "popularity" ? popularity : dragon; + return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) }); + }); + await page.route("**/api/market/themes/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(themeDetail) })); + await page.route("**/api/market/entities/theme/*/charts/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ entity_type: "theme", identifier: "885001.TI", code: "885001", name: "机器人", interval: "day", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", previous_close: 1000, range_start: null, range_end: null, points: [{ time: "2026-07-29", open: 1000, high: 1020, low: 990, close: 1010, volume: 100, amount: 1000, average: null }, { time: "2026-07-30", open: 1010, high: 1060, low: 1005, close: 1050, volume: 150, amount: 1600, average: null }] }) })); +} + +test("stage 8 market insights preserve lifecycle, hierarchy and empty-state semantics", async ({ page }) => { + const consoleErrors = []; + page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); }); + await mockStage8(page); + await authenticate(page); + + await page.getByRole("link", { name: /集合竞价/ }).click(); + await expect(page.getByText("竞价成交额", { exact: true })).toBeVisible(); + await expect(page.getByText("42.03 亿", { exact: true })).toBeVisible(); + await expect(page.locator(".auction-bars").getByText("42.03", { exact: true })).toBeVisible(); + await page.screenshot({ path: path.join(evidence, "auction-light-1920x1080.jpg"), type: "jpeg", quality: 82 }); + + await page.getByRole("link", { name: /题材库/ }).click(); + await expect(page.getByRole("heading", { name: "机器人成分股" })).toHaveCount(0); + await expect(page.getByText("停牌样本")).toBeVisible(); + await page.getByRole("button", { name: /机器人/ }).first().hover(); + await expect(page.locator(".theme-preview-popover")).toBeVisible(); + + await page.getByRole("link", { name: /人气热榜/ }).click(); + await page.getByRole("button", { name: "夜间" }).click(); + await expect(page.locator(".popularity-summary-strip article")).toHaveCount(3); + expect(await page.locator(".popularity-summary-strip article").first().evaluate((element) => getComputedStyle(element).boxShadow)).toBe("none"); + await page.screenshot({ path: path.join(evidence, "popularity-dark-1920x1080.jpg"), type: "jpeg", quality: 82 }); + + await page.getByRole("link", { name: /龙虎榜/ }).click(); + await expect(page.getByRole("heading", { name: "当日操作明细" })).toBeVisible(); + await expect(page.getByText("测试待归类营业部").first()).toBeVisible(); + await page.getByRole("button", { name: "游资档案" }).click(); + await expect(page.getByRole("heading", { name: "章盟主" })).toBeVisible(); + await expect(page.locator(".dialog-backdrop")).toHaveCount(0); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); + await page.screenshot({ path: path.join(evidence, "dragon-profiles-dark-390x844.jpg"), type: "jpeg", quality: 82 }); + expect(consoleErrors).toEqual([]); +}); diff --git a/next/tests/test_market_insights.py b/next/tests/test_market_insights.py new file mode 100644 index 0000000..34ae574 --- /dev/null +++ b/next/tests/test_market_insights.py @@ -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"] diff --git a/next/tests/test_migrations.py b/next/tests/test_migrations.py index 1363c57..c4544e1 100644 --- a/next/tests/test_migrations.py +++ b/next/tests/test_migrations.py @@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None: database = Database(tmp_path / "app.db") runner = MigrationRunner(database) - assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4) + assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6) assert { "users", "memberships", @@ -125,8 +125,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None: "market_summaries", "chart_series", "sector_member_snapshots", + "market_insight_snapshots", + "seat_aliases", + "watchlist_entries", } <= table_names(database) - assert runner.downgrade(MIGRATIONS, target_version=0) == (4, 3, 2, 1) + assert runner.downgrade(MIGRATIONS, target_version=0) == (6, 5, 4, 3, 2, 1) assert "users" not in table_names(database) assert "llm_models" not in table_names(database)