From 976a5cac03c4a253b318dced8e084a328d7aa5d2 Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 30 Jul 2026 04:12:04 +0800 Subject: [PATCH] rebuild(stage-8): deliver market insight workspaces --- next/backend/bootstrap/container.py | 5 +- next/backend/data/gateway.py | 34 ++ next/backend/data/policy.py | 2 + next/backend/data/providers/base.py | 12 + next/backend/data/providers/eastmoney.py | 14 + next/backend/data/providers/ifind.py | 45 ++ next/backend/data/providers/tushare.py | 103 ++++ next/backend/data/repository.py | 169 ++++++ .../migrations/m0005_market_insights.py | 49 ++ .../database/migrations/m0006_watchlists.py | 33 ++ next/backend/database/migrations/registry.py | 11 +- .../features/market/insights/__init__.py | 3 + .../features/market/insights/auction.py | 519 ++++++++++++++++++ .../features/market/insights/dragon.py | 302 ++++++++++ .../features/market/insights/popularity.py | 111 ++++ .../features/market/insights/service.py | 490 +++++++++++++++++ .../features/market/insights/support.py | 128 +++++ .../features/market/insights/themes.py | 124 +++++ next/backend/features/market/routes.py | 53 ++ next/backend/features/market/schemas.py | 36 +- next/backend/features/market/service.py | 42 +- next/docs/evidence/stage-8/README.md | 54 ++ .../stage-8/auction-light-1920x1080.jpg | Bin 0 -> 97727 bytes .../stage-8/dragon-profiles-dark-390x844.jpg | Bin 0 -> 32503 bytes .../stage-8/popularity-dark-1920x1080.jpg | Bin 0 -> 90967 bytes next/frontend/src/app/views/WorkspaceView.vue | 5 +- next/frontend/src/main.ts | 1 + .../src/pages/market/MarketWorkspaceView.vue | 19 +- .../src/pages/market/insights/AuctionPage.vue | 112 ++++ .../src/pages/market/insights/DragonPage.vue | 110 ++++ .../pages/market/insights/PopularityPage.vue | 66 +++ .../src/pages/market/insights/ThemesPage.vue | 109 ++++ next/frontend/src/shared/api/market.ts | 53 ++ next/frontend/src/shared/market/table.ts | 43 ++ .../src/shared/styles/market-insights.css | 187 +++++++ next/frontend/src/shared/styles/mobile.css | 89 +++ next/tests/e2e/stage8.spec.js | 144 +++++ next/tests/test_market_insights.py | 401 ++++++++++++++ next/tests/test_migrations.py | 7 +- 39 files changed, 3671 insertions(+), 14 deletions(-) create mode 100644 next/backend/database/migrations/m0005_market_insights.py create mode 100644 next/backend/database/migrations/m0006_watchlists.py create mode 100644 next/backend/features/market/insights/__init__.py create mode 100644 next/backend/features/market/insights/auction.py create mode 100644 next/backend/features/market/insights/dragon.py create mode 100644 next/backend/features/market/insights/popularity.py create mode 100644 next/backend/features/market/insights/service.py create mode 100644 next/backend/features/market/insights/support.py create mode 100644 next/backend/features/market/insights/themes.py create mode 100644 next/docs/evidence/stage-8/README.md create mode 100644 next/docs/evidence/stage-8/auction-light-1920x1080.jpg create mode 100644 next/docs/evidence/stage-8/dragon-profiles-dark-390x844.jpg create mode 100644 next/docs/evidence/stage-8/popularity-dark-1920x1080.jpg create mode 100644 next/frontend/src/pages/market/insights/AuctionPage.vue create mode 100644 next/frontend/src/pages/market/insights/DragonPage.vue create mode 100644 next/frontend/src/pages/market/insights/PopularityPage.vue create mode 100644 next/frontend/src/pages/market/insights/ThemesPage.vue create mode 100644 next/frontend/src/shared/market/table.ts create mode 100644 next/frontend/src/shared/styles/market-insights.css create mode 100644 next/tests/e2e/stage8.spec.js create mode 100644 next/tests/test_market_insights.py 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 0000000000000000000000000000000000000000..5400f538f732de5e936bc669708bffd694c1d6ef GIT binary patch literal 97727 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

~y=jGsTBvmWfBQCA?Ge+#<~EDqzIm|WZ5o5{Po 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8be06ca918047d4f99ced27e57014bc2bcd0711e GIT binary patch literal 32503 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c386d6d7c42aa598dd0202a70a10a44cdc7970ec GIT binary patch literal 90967 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 z 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 ["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)