diff --git a/app/backend/application.py b/app/backend/application.py index 8aff479..ea5bbdd 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -42,7 +42,6 @@ from heaven_engine import ( from backend.data.providers.ifind_client import IfindError from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection from mentor_agent import MentorAgentError, stream_with_mentor -from market_insights import MarketInsightsService from screener import ( FACTOR_FIELDS, FACTOR_GROUPS, @@ -53,10 +52,14 @@ from screener import ( from backend.features.accounts.http import AccountHttpMixin from backend.features.accounts.security import SecretVault from backend.features.accounts.service import AccountService +from backend.features.auction import AuctionServiceMixin +from backend.features.dragon_tiger import DragonTigerServiceMixin from backend.features.pools import PoolServiceMixin +from backend.features.popularity import PopularityServiceMixin from backend.features.rotation import RotationServiceMixin from backend.features.sentiment import SentimentServiceMixin from backend.features.system import SystemHttpMixin +from backend.features.themes import ThemeServiceMixin from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue @@ -142,6 +145,10 @@ class DashboardService( SentimentServiceMixin, PoolServiceMixin, RotationServiceMixin, + AuctionServiceMixin, + ThemeServiceMixin, + PopularityServiceMixin, + DragonTigerServiceMixin, ): def __init__(self) -> None: runtime = load_runtime_settings() @@ -753,28 +760,9 @@ class DashboardService( } - def _market_insights(self) -> MarketInsightsService: - if not self.configured: - raise ValueError("行情数据尚未配置。") - return MarketInsightsService( - self.database, - self._tushare_client(), - ifind=self.ifind, - ) - def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]: - return self._market_insights().auction_center( - normalize_date(trade_date), force, self.current_user_id - ) - def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]: - return self._market_insights().theme_library(normalize_date(trade_date), force) - def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]: - return self._market_insights().theme_detail(code, normalize_date(trade_date)) - - def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]: - return self._market_insights().popularity(normalize_date(trade_date), force) @staticmethod def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: @@ -3074,287 +3062,6 @@ class DashboardService( ) return result - def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]: - cache_kind = "hot_money_profiles_v1" - cache_key = "directory" - cached = self.database.get_data_snapshot(cache_kind, cache_key) - if cached and not force: - cached["meta"] = {**cached.get("meta", {}), "cached": True} - return cached - if self.configured: - try: - payload = self._tushare_client().hot_money_profiles() - except TushareError: - if cached: - cached["meta"] = { - **cached.get("meta", {}), - "cached": True, - "stale": True, - "notice": "名录暂未完成更新,当前展示最近一次收录结果。", - } - return cached - return { - "meta": { - "source": "unavailable", - "status": "unavailable", - "schema_version": 1, - "cached": False, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": "游资名录暂不可用,请稍后重试。", - }, - "summary": { - "profile_count": 0, - "described_count": 0, - "organization_count": 0, - }, - "profiles": [], - } - payload["meta"]["cached"] = False - if payload.get("meta", {}).get("status") == "success": - self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload) - return payload - if cached: - cached["meta"] = {**cached.get("meta", {}), "cached": True} - return cached - return { - "meta": { - "source": "unavailable", - "status": "unavailable", - "schema_version": 1, - "cached": False, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": "游资名录暂不可用,请联系管理员检查行情配置。", - }, - "summary": { - "profile_count": 0, - "described_count": 0, - "organization_count": 0, - }, - "profiles": [], - } - - def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - cache_kind = "hot_money_detail_v3" - if not force: - cached = self.database.get_data_snapshot(cache_kind, normalized_date) - if ( - cached - and cached.get("meta", {}).get("source") == "tushare" - and cached.get("meta", {}).get("status") == "success" - and int(cached.get("meta", {}).get("schema_version") or 0) == 3 - ): - cached["meta"] = {**cached.get("meta", {}), "cached": True} - return cached - if self.configured: - try: - payload = self._tushare_client().dragon_tiger(normalized_date) - except TushareError as exc: - return { - "meta": { - "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", - "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", - "source": "tushare_error", - "status": "error", - "schema_version": 3, - "cached": False, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": "龙虎榜数据暂不可用,请稍后重试。", - }, - "summary": { - "trader_count": 0, - "identity_count": 0, - "operation_count": 0, - "active_stock_count": 0, - "seat_net_buy_million": 0, - "unclassified_count": 0, - "directory_count": 0, - }, - "traders": [], - "unclassified_seats": [], - "rows": [], - } - payload["meta"]["cached"] = False - if payload.get("meta", {}).get("status") == "success": - self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload) - return payload - - return { - "meta": { - "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", - "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", - "source": "unavailable", - "status": "unavailable", - "schema_version": 3, - "cached": False, - "notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。", - }, - "summary": { - "trader_count": 0, - "identity_count": 0, - "operation_count": 0, - "active_stock_count": 0, - "seat_net_buy_million": 0, - "unclassified_count": 0, - "directory_count": 0, - }, - "traders": [], - "unclassified_seats": [], - "rows": [], - } - - - - def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]: - aliases = self.database.list_seat_aliases() - result = dict(payload) - rows = payload.get("rows") or [] - for row in rows: - for institution in row.get("institutions") or []: - institution["alias"] = aliases.get(institution.get("seat_name", ""), "") - traders: dict[tuple[str, str], dict[str, Any]] = {} - unclassified: dict[str, dict[str, Any]] = {} - seen_operations: set[tuple[Any, ...]] = set() - builtin_aliases = { - "国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一", - } - - for row in rows: - for institution in row.get("institutions") or []: - seat_name = str(institution.get("seat_name") or "未知席位").strip() - saved_alias = str(institution.get("alias") or "").strip() - builtin_alias = builtin_aliases.get(seat_name, "") - if saved_alias or builtin_alias: - identity_name = saved_alias or builtin_alias - identity_type = "trader" - recognized = True - identity_source = "manual" if saved_alias else "builtin" - elif "机构专用" in seat_name: - identity_name = "机构专用" - identity_type = "institution" - recognized = True - identity_source = "system" - elif "沪股通专用" in seat_name or "深股通专用" in seat_name: - identity_name = "北向资金" - identity_type = "channel" - recognized = True - identity_source = "system" - else: - identity_name = seat_name - identity_type = "unclassified" - recognized = False - identity_source = "raw" - - buy = round(float(institution.get("buy_million") or 0), 2) - sell = round(float(institution.get("sell_million") or 0), 2) - net_buy = round(float(institution.get("net_buy_million") or 0), 2) - operation_key = (row.get("code"), seat_name, buy, sell, net_buy) - if operation_key in seen_operations: - continue - seen_operations.add(operation_key) - - group_key = (identity_type, identity_name) - group = traders.setdefault( - group_key, - { - "name": identity_name, - "identity_type": identity_type, - "identity_source": identity_source, - "recognized": recognized, - "buy_million": 0.0, - "sell_million": 0.0, - "net_buy_million": 0.0, - "seat_names": set(), - "stock_codes": set(), - "operations": [], - }, - ) - group["buy_million"] += buy - group["sell_million"] += sell - group["net_buy_million"] += net_buy - group["seat_names"].add(seat_name) - group["stock_codes"].add(str(row.get("code") or "")) - group["operations"].append( - { - "code": row.get("code") or "", - "name": row.get("name") or "--", - "change": row.get("change") or 0, - "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平", - "buy_million": buy, - "sell_million": sell, - "net_buy_million": net_buy, - "reason": row.get("reason") or "--", - "seat_name": seat_name, - "seat_alias": identity_name if recognized else "", - } - ) - - if not recognized: - pending = unclassified.setdefault( - seat_name, - { - "seat_name": seat_name, - "stock_codes": set(), - "operation_count": 0, - "buy_million": 0.0, - "sell_million": 0.0, - "net_buy_million": 0.0, - }, - ) - pending["stock_codes"].add(str(row.get("code") or "")) - pending["operation_count"] += 1 - pending["buy_million"] += buy - pending["sell_million"] += sell - pending["net_buy_million"] += net_buy - - type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3} - aggregated = list(traders.values()) - aggregated.sort( - key=lambda item: ( - type_order.get(item["identity_type"], 9), - -abs(item["net_buy_million"]), - item["name"], - ) - ) - for index, group in enumerate(aggregated, start=1): - group["id"] = f"identity-{index}" - group["buy_million"] = round(group["buy_million"], 2) - group["sell_million"] = round(group["sell_million"], 2) - group["net_buy_million"] = round(group["net_buy_million"], 2) - group["seat_count"] = len(group.pop("seat_names")) - group["stock_count"] = len(group.pop("stock_codes")) - group["operation_count"] = len(group["operations"]) - group["operations"].sort( - key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True - ) - - pending_seats = list(unclassified.values()) - for pending in pending_seats: - pending["stock_count"] = len(pending.pop("stock_codes")) - pending["buy_million"] = round(pending["buy_million"], 2) - pending["sell_million"] = round(pending["sell_million"], 2) - pending["net_buy_million"] = round(pending["net_buy_million"], 2) - pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True) - - operation_count = sum(item["operation_count"] for item in aggregated) - active_stocks = { - operation["code"] for item in aggregated for operation in item["operations"] - } - seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2) - result["rows"] = rows - result["traders"] = aggregated - result["unclassified_seats"] = pending_seats - result["summary"] = { - **(payload.get("summary") or {}), - "trader_count": sum(item["identity_type"] == "trader" for item in aggregated), - "identity_count": len(aggregated), - "operation_count": operation_count, - "active_stock_count": len(active_stocks), - "seat_net_buy_million": seat_net_buy, - "unclassified_count": len(pending_seats), - } - return result - SERVICE = DashboardService() diff --git a/app/backend/features/auction/__init__.py b/app/backend/features/auction/__init__.py new file mode 100644 index 0000000..df48ecf --- /dev/null +++ b/app/backend/features/auction/__init__.py @@ -0,0 +1,4 @@ +from .repository import AuctionRepositoryMixin +from .service import AuctionServiceMixin + +__all__ = ["AuctionRepositoryMixin", "AuctionServiceMixin"] diff --git a/app/backend/features/auction/repository.py b/app/backend/features/auction/repository.py new file mode 100644 index 0000000..85d1a2e --- /dev/null +++ b/app/backend/features/auction/repository.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + + +class AuctionRepositoryMixin: + def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int: + values = [] + for row in rows: + trade_date = str(row.get("trade_date") or "") + ts_code = str(row.get("ts_code") or "") + price = float(row.get("price") or 0) + pre_close = float(row.get("pre_close") or 0) + if not trade_date or not ts_code or price <= 0 or pre_close <= 0: + continue + values.append( + ( + trade_date, + ts_code, + price, + pre_close, + (price / pre_close - 1) * 100, + float(row.get("vol") or 0), + float(row.get("amount") or 0), + float(row.get("turnover_rate") or 0), + float(row.get("volume_ratio") or 0), + ) + ) + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO auction_factors + (trade_date, ts_code, price, pre_close, change, vol, amount, + turnover_rate, volume_ratio) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + price=excluded.price, pre_close=excluded.pre_close, + change=excluded.change, vol=excluded.vol, amount=excluded.amount, + turnover_rate=excluded.turnover_rate, + volume_ratio=excluded.volume_ratio + """, + values, + ) + return len(values) + + def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: + where = "WHERE trade_date <= ?" if end_date else "" + parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) + with self.connect() as connection: + rows = connection.execute( + f"SELECT DISTINCT trade_date FROM auction_factors {where} " + "ORDER BY trade_date DESC LIMIT ?", + parameters, + ).fetchall() + return [row["trade_date"] for row in reversed(rows)] + + def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code", + (trade_date,), + ).fetchall() + return [dict(row) for row in rows] diff --git a/app/backend/features/auction/service.py b/app/backend/features/auction/service.py new file mode 100644 index 0000000..456f261 --- /dev/null +++ b/app/backend/features/auction/service.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from backend.bootstrap.config import normalize_date +from backend.features.market.insights import MarketInsightsService + + +class AuctionServiceMixin: + def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]: + return self._market_insights().auction_center( + normalize_date(trade_date), force, self.current_user_id + ) diff --git a/app/backend/features/dragon_tiger/__init__.py b/app/backend/features/dragon_tiger/__init__.py new file mode 100644 index 0000000..fe18b6b --- /dev/null +++ b/app/backend/features/dragon_tiger/__init__.py @@ -0,0 +1,4 @@ +from .repository import DragonTigerRepositoryMixin +from .service import DragonTigerServiceMixin + +__all__ = ["DragonTigerRepositoryMixin", "DragonTigerServiceMixin"] diff --git a/app/backend/features/dragon_tiger/repository.py b/app/backend/features/dragon_tiger/repository.py new file mode 100644 index 0000000..d8c43c4 --- /dev/null +++ b/app/backend/features/dragon_tiger/repository.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +class DragonTigerRepositoryMixin: + def list_seat_aliases(self) -> dict[str, str]: + with self.connect() as connection: + rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall() + return {row["seat_name"]: row["alias"] for row in rows} + + def save_seat_alias(self, seat_name: str, alias: str) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO seat_aliases (seat_name, alias, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(seat_name) DO UPDATE SET + alias = excluded.alias, + updated_at = excluded.updated_at + """, + (seat_name, alias, now), + ) + + def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int: + grouped: dict[tuple[str, str], dict[str, float | int]] = {} + for row in rows: + trade_date = str(row.get("trade_date") or "") + ts_code = str(row.get("ts_code") or "") + seat_name = str(row.get("exalter") or row.get("seat_name") or "") + if not trade_date or not ts_code or "机构专用" not in seat_name: + continue + group = grouped.setdefault( + (trade_date, ts_code), + {"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0}, + ) + group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0) + group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0) + group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0) + group["seats"] = int(group["seats"]) + 1 + values = [ + (trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"]) + for (trade_date, ts_code), item in grouped.items() + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO lhb_institution_daily + (trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + net_buy_amount=excluded.net_buy_amount, + buy_amount=excluded.buy_amount, + sell_amount=excluded.sell_amount, + seat_count=excluded.seat_count + """, + values, + ) + return len(values) diff --git a/app/backend/features/dragon_tiger/service.py b/app/backend/features/dragon_tiger/service.py new file mode 100644 index 0000000..b86ebd2 --- /dev/null +++ b/app/backend/features/dragon_tiger/service.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from backend.bootstrap.config import normalize_date +from backend.data.providers.tushare_client import TushareError + + +class DragonTigerServiceMixin: + def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]: + cache_kind = "hot_money_profiles_v1" + cache_key = "directory" + cached = self.database.get_data_snapshot(cache_kind, cache_key) + if cached and not force: + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return cached + if self.configured: + try: + payload = self._tushare_client().hot_money_profiles() + except TushareError: + if cached: + cached["meta"] = { + **cached.get("meta", {}), + "cached": True, + "stale": True, + "notice": "名录暂未完成更新,当前展示最近一次收录结果。", + } + return cached + return { + "meta": { + "source": "unavailable", + "status": "unavailable", + "schema_version": 1, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "游资名录暂不可用,请稍后重试。", + }, + "summary": { + "profile_count": 0, + "described_count": 0, + "organization_count": 0, + }, + "profiles": [], + } + payload["meta"]["cached"] = False + if payload.get("meta", {}).get("status") == "success": + self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload) + return payload + if cached: + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return cached + return { + "meta": { + "source": "unavailable", + "status": "unavailable", + "schema_version": 1, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "游资名录暂不可用,请联系管理员检查行情配置。", + }, + "summary": { + "profile_count": 0, + "described_count": 0, + "organization_count": 0, + }, + "profiles": [], + } + + def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + cache_kind = "hot_money_detail_v3" + if not force: + cached = self.database.get_data_snapshot(cache_kind, normalized_date) + if ( + cached + and cached.get("meta", {}).get("source") == "tushare" + and cached.get("meta", {}).get("status") == "success" + and int(cached.get("meta", {}).get("schema_version") or 0) == 3 + ): + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return cached + if self.configured: + try: + payload = self._tushare_client().dragon_tiger(normalized_date) + except TushareError as exc: + return { + "meta": { + "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", + "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", + "source": "tushare_error", + "status": "error", + "schema_version": 3, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "龙虎榜数据暂不可用,请稍后重试。", + }, + "summary": { + "trader_count": 0, + "identity_count": 0, + "operation_count": 0, + "active_stock_count": 0, + "seat_net_buy_million": 0, + "unclassified_count": 0, + "directory_count": 0, + }, + "traders": [], + "unclassified_seats": [], + "rows": [], + } + payload["meta"]["cached"] = False + if payload.get("meta", {}).get("status") == "success": + self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload) + return payload + + return { + "meta": { + "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", + "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", + "source": "unavailable", + "status": "unavailable", + "schema_version": 3, + "cached": False, + "notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。", + }, + "summary": { + "trader_count": 0, + "identity_count": 0, + "operation_count": 0, + "active_stock_count": 0, + "seat_net_buy_million": 0, + "unclassified_count": 0, + "directory_count": 0, + }, + "traders": [], + "unclassified_seats": [], + "rows": [], + } + + def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]: + aliases = self.database.list_seat_aliases() + result = dict(payload) + rows = payload.get("rows") or [] + for row in rows: + for institution in row.get("institutions") or []: + institution["alias"] = aliases.get(institution.get("seat_name", ""), "") + traders: dict[tuple[str, str], dict[str, Any]] = {} + unclassified: dict[str, dict[str, Any]] = {} + seen_operations: set[tuple[Any, ...]] = set() + builtin_aliases = { + "国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一", + } + + for row in rows: + for institution in row.get("institutions") or []: + seat_name = str(institution.get("seat_name") or "未知席位").strip() + saved_alias = str(institution.get("alias") or "").strip() + builtin_alias = builtin_aliases.get(seat_name, "") + if saved_alias or builtin_alias: + identity_name = saved_alias or builtin_alias + identity_type = "trader" + recognized = True + identity_source = "manual" if saved_alias else "builtin" + elif "机构专用" in seat_name: + identity_name = "机构专用" + identity_type = "institution" + recognized = True + identity_source = "system" + elif "沪股通专用" in seat_name or "深股通专用" in seat_name: + identity_name = "北向资金" + identity_type = "channel" + recognized = True + identity_source = "system" + else: + identity_name = seat_name + identity_type = "unclassified" + recognized = False + identity_source = "raw" + + buy = round(float(institution.get("buy_million") or 0), 2) + sell = round(float(institution.get("sell_million") or 0), 2) + net_buy = round(float(institution.get("net_buy_million") or 0), 2) + operation_key = (row.get("code"), seat_name, buy, sell, net_buy) + if operation_key in seen_operations: + continue + seen_operations.add(operation_key) + + group_key = (identity_type, identity_name) + group = traders.setdefault( + group_key, + { + "name": identity_name, + "identity_type": identity_type, + "identity_source": identity_source, + "recognized": recognized, + "buy_million": 0.0, + "sell_million": 0.0, + "net_buy_million": 0.0, + "seat_names": set(), + "stock_codes": set(), + "operations": [], + }, + ) + group["buy_million"] += buy + group["sell_million"] += sell + group["net_buy_million"] += net_buy + group["seat_names"].add(seat_name) + group["stock_codes"].add(str(row.get("code") or "")) + group["operations"].append( + { + "code": row.get("code") or "", + "name": row.get("name") or "--", + "change": row.get("change") or 0, + "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平", + "buy_million": buy, + "sell_million": sell, + "net_buy_million": net_buy, + "reason": row.get("reason") or "--", + "seat_name": seat_name, + "seat_alias": identity_name if recognized else "", + } + ) + + if not recognized: + pending = unclassified.setdefault( + seat_name, + { + "seat_name": seat_name, + "stock_codes": set(), + "operation_count": 0, + "buy_million": 0.0, + "sell_million": 0.0, + "net_buy_million": 0.0, + }, + ) + pending["stock_codes"].add(str(row.get("code") or "")) + pending["operation_count"] += 1 + pending["buy_million"] += buy + pending["sell_million"] += sell + pending["net_buy_million"] += net_buy + + type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3} + aggregated = list(traders.values()) + aggregated.sort( + key=lambda item: ( + type_order.get(item["identity_type"], 9), + -abs(item["net_buy_million"]), + item["name"], + ) + ) + for index, group in enumerate(aggregated, start=1): + group["id"] = f"identity-{index}" + group["buy_million"] = round(group["buy_million"], 2) + group["sell_million"] = round(group["sell_million"], 2) + group["net_buy_million"] = round(group["net_buy_million"], 2) + group["seat_count"] = len(group.pop("seat_names")) + group["stock_count"] = len(group.pop("stock_codes")) + group["operation_count"] = len(group["operations"]) + group["operations"].sort( + key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True + ) + + pending_seats = list(unclassified.values()) + for pending in pending_seats: + pending["stock_count"] = len(pending.pop("stock_codes")) + pending["buy_million"] = round(pending["buy_million"], 2) + pending["sell_million"] = round(pending["sell_million"], 2) + pending["net_buy_million"] = round(pending["net_buy_million"], 2) + pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True) + + operation_count = sum(item["operation_count"] for item in aggregated) + active_stocks = { + operation["code"] for item in aggregated for operation in item["operations"] + } + seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2) + result["rows"] = rows + result["traders"] = aggregated + result["unclassified_seats"] = pending_seats + result["summary"] = { + **(payload.get("summary") or {}), + "trader_count": sum(item["identity_type"] == "trader" for item in aggregated), + "identity_count": len(aggregated), + "operation_count": operation_count, + "active_stock_count": len(active_stocks), + "seat_net_buy_million": seat_net_buy, + "unclassified_count": len(pending_seats), + } + return result diff --git a/app/backend/features/market/insights.py b/app/backend/features/market/insights.py new file mode 100644 index 0000000..226521d --- /dev/null +++ b/app/backend/features/market/insights.py @@ -0,0 +1,1314 @@ +from __future__ import annotations + +import copy +import json +from datetime import datetime, time as dt_time, timedelta, timezone +from statistics import median +from typing import TYPE_CHECKING, Any, Callable + +from backend.data.providers.ifind_client import IfindError, IfindHttpClient +from backend.data.providers.tushare_client import TushareClient, TushareError + +if TYPE_CHECKING: + from database import ReviewDatabase + + +CHINA_TIMEZONE = timezone(timedelta(hours=8)) + + +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 + + +def _display_date(value: str) -> str: + text = str(value or "").replace("-", "") + if len(text) != 8: + return str(value or "") + return f"{text[:4]}-{text[4:6]}-{text[6:]}" + + +class MarketInsightsService: + """Read-only market features backed by Tushare and shared SQLite caches.""" + + def __init__( + self, + database: ReviewDatabase, + client: TushareClient, + now_provider: Callable[[], datetime] | None = None, + ifind: IfindHttpClient | None = None, + ) -> None: + self.database = database + self.client = client + self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE)) + self.ifind = ifind + + def _trade_context(self, requested_date: str) -> tuple[str, str]: + """Resolve trading dates without making cached feature pages depend on Tushare uptime.""" + requested = str(requested_date or "").replace("-", "") + try: + return self.client.resolve_trade_context(requested) + except TushareError: + latest = self.database.get_latest_real_snapshot(requested) or {} + trade_date = str( + (latest.get("meta") or {}).get("trade_date") + or latest.get("_snapshot_date") + or requested + ).replace("-", "") + previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {} + previous_date = str( + (previous.get("meta") or {}).get("trade_date") + or previous.get("_snapshot_date") + or "" + ).replace("-", "") + return trade_date, previous_date + + def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None: + return self.database.get_latest_data_snapshot(kind, "", trade_date) + + def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]: + now = self._now_provider() + if now.tzinfo is None: + now = now.replace(tzinfo=CHINA_TIMEZONE) + else: + now = now.astimezone(CHINA_TIMEZONE) + requested = str(requested_date or "").replace("-", "") + today = now.strftime("%Y%m%d") + if requested != today or trade_date != today: + return { + "phase": "archive", + "actionable": False, + "next_transition_at": "", + } + + local_time = now.time().replace(tzinfo=None) + transitions = ( + (dt_time(9, 15), "pending", dt_time(9, 15)), + (dt_time(9, 25), "observing", dt_time(9, 25)), + (dt_time(9, 30), "selection", dt_time(9, 30)), + ) + for boundary, phase, next_boundary in transitions: + if local_time < boundary: + transition = now.replace( + hour=next_boundary.hour, + minute=next_boundary.minute, + second=0, + microsecond=0, + ) + return { + "phase": phase, + "actionable": phase == "selection", + "next_transition_at": transition.isoformat(timespec="seconds"), + } + return { + "phase": "finalized", + "actionable": False, + "next_transition_at": "", + } + + def _stock_master(self) -> dict[str, dict[str, Any]]: + rows = self.database.list_stock_master() + if not rows: + rows = self.client.query( + "stock_basic", + {"list_status": "L"}, + "ts_code,name,industry,market,list_date", + ) + self.database.upsert_stock_master(rows) + rows = self.database.list_stock_master() + return {str(row.get("ts_code") or ""): row for row in rows} + + @staticmethod + def _expectation_label(actual_strength: float, expected_change: float) -> str: + difference = actual_strength - expected_change + if difference >= 1.5: + return "超预期" + if difference <= -1.5: + return "低于预期" + return "符合预期" + + @staticmethod + def _auction_confirmation(row: dict[str, Any]) -> float: + volume_ratio = _number(row.get("volume_ratio")) + turnover_rate = _number(row.get("turnover_rate")) + amount_million = _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_rate >= 0.15 else -0.25 if turnover_rate < 0.03 else 0) + + (0.3 if amount_million >= 20 else 0.15 if amount_million >= 5 else -0.3 if amount_million < 1 else 0) + ) + + @staticmethod + def _attention_score( + row: dict[str, Any], + expected_change: float, + core_tags: list[str], + sources: list[str], + prior_streak: int, + strong_sector: bool, + ) -> float: + if core_tags: + identity_score = 35.0 + elif prior_streak >= 2: + identity_score = 27.0 + elif any(source in {"昨日涨停", "昨日炸板"} for source in sources): + identity_score = 21.0 + else: + identity_score = 14.0 + deviation_score = min(30.0, abs(_number(row.get("change")) - expected_change) * 5) + volume_score = min(10.0, max(0.0, _number(row.get("volume_ratio"))) / 2 * 10) + amount_score = min(6.0, max(0.0, _number(row.get("amount_million"))) / 10 * 6) + turnover_score = min(4.0, max(0.0, _number(row.get("turnover_rate"))) / 0.2 * 4) + theme_score = 15.0 if strong_sector else 7.0 if row.get("concepts") else 0.0 + return round(min(100.0, identity_score + deviation_score + volume_score + amount_score + turnover_score + theme_score), 1) + + def _auction_candidates( + self, + rows: list[dict[str, Any]], + baseline_date: str, + ) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]: + """Build a narrow, explainable universe from prior limits, breaks and top-20 hot lists.""" + snapshot = self.database.get_snapshot(baseline_date) or {} + prior_limits = list(snapshot.get("limits") or []) + prior_broken = list(snapshot.get("broken") or []) + prior_sectors = list(snapshot.get("sectors") or []) + strong_sector_names = { + str(item.get("name") or "") for item in prior_sectors[:5] if item.get("name") + } + ths_rows, dc_rows, errors = self._hot_rows(baseline_date) + candidates: dict[str, dict[str, Any]] = {} + core_tags: dict[str, set[str]] = {} + + def ensure_candidate(item: dict[str, Any]) -> dict[str, Any] | None: + code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0]) + if not code: + return None + return candidates.setdefault( + code, + { + "sources": [], + "streak": 0, + "sector": str(item.get("sector") or "其他"), + "name": str(item.get("name") or item.get("ts_name") or "--"), + "concepts": [], + "ths_rank": None, + "dc_rank": None, + }, + ) + + for item in prior_limits: + candidate = ensure_candidate(item) + if candidate is None: + continue + candidate["sources"].append("昨日涨停") + candidate["streak"] = max(1, int(_number(item.get("streak"), 1))) + + for item in prior_broken: + candidate = ensure_candidate(item) + if candidate is not None and "昨日炸板" not in candidate["sources"]: + candidate["sources"].append("昨日炸板") + + limit_streaks = [max(1, int(_number(item.get("streak"), 1))) for item in prior_limits] + highest_streak = max(limit_streaks, default=0) + for item in prior_limits: + code = str(item.get("code") or "") + streak = max(1, int(_number(item.get("streak"), 1))) + if streak >= 3: + core_tags.setdefault(code, set()).add("三板以上") + if highest_streak and streak == highest_streak: + core_tags.setdefault(code, set()).add("市场最高板") + + 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 not members: + continue + leader = max( + members, + key=lambda item: ( + int(_number(item.get("streak"), 1)), + _number(item.get("amount_billion")), + -_number(item.get("open_times")), + ), + ) + core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心") + + leadership = sorted( + prior_limits, + key=lambda item: ( + int(_number(item.get("streak"), 1)), + str(item.get("sector") or "") in strong_sector_names, + _number(item.get("amount_billion")), + ), + reverse=True, + ) + if leadership: + core_tags.setdefault(str(leadership[0].get("code") or ""), set()).add("市场领涨") + + hot_records: dict[str, dict[str, Any]] = {} + + for source, hot_rows, data_type in ( + ("同花顺热榜", ths_rows, "热股"), + ("东方财富热榜", dc_rows, "A股市场"), + ): + for item in hot_rows: + if str(item.get("data_type") or "") != data_type: + continue + ts_code = str(item.get("ts_code") or "") + code = ts_code.split(".")[0] + rank = max(1, int(_number(item.get("rank"), 9999))) + if not code or rank > 20: + continue + hot = hot_records.setdefault( + code, + { + "name": str(item.get("ts_name") or "--"), + "concepts": [], + "ths_rank": None, + "dc_rank": None, + }, + ) + hot["ths_rank" if source == "同花顺热榜" else "dc_rank"] = rank + if source == "同花顺热榜": + hot["concepts"] = self._parse_concepts(item.get("concept")) + + ranked_hot = sorted( + hot_records.items(), + key=lambda pair: ( + ((21 - (pair[1].get("ths_rank") or 21)) / 20) + + ((21 - (pair[1].get("dc_rank") or 21)) / 20) + + (0.35 if pair[1].get("ths_rank") and pair[1].get("dc_rank") else 0) + ), + reverse=True, + ) + for code, _ in ranked_hot[:5]: + core_tags.setdefault(code, set()).add("人气前5") + + for code, hot in hot_records.items(): + ranks = [rank for rank in (hot.get("ths_rank"), hot.get("dc_rank")) if isinstance(rank, int)] + dual = len(ranks) == 2 + if not ranks or (min(ranks) > 10 and not dual and code not in candidates and code not in core_tags): + continue + candidate = candidates.setdefault( + code, + { + "sources": [], + "streak": 0, + "sector": "其他", + "name": hot["name"], + "concepts": [], + "ths_rank": None, + "dc_rank": None, + }, + ) + candidate["ths_rank"] = hot.get("ths_rank") + candidate["dc_rank"] = hot.get("dc_rank") + candidate["concepts"] = hot.get("concepts") or [] + if hot.get("ths_rank") and "同花顺热榜" not in candidate["sources"]: + candidate["sources"].append("同花顺热榜") + if hot.get("dc_rank") and "东方财富热榜" not in candidate["sources"]: + candidate["sources"].append("东方财富热榜") + + normalized = [] + for row in rows: + candidate = candidates.get(str(row.get("code") or "")) + if not candidate: + continue + streak = int(candidate["streak"]) + expected_change = {1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0 if streak else 0.5) + ranks = [ + rank for rank in (candidate.get("ths_rank"), candidate.get("dc_rank")) + if isinstance(rank, int) + ] + if len(ranks) == 2: + expected_change += 0.8 + elif ranks: + best_rank = min(ranks) + expected_change += 0.7 if best_rank <= 10 else 0.4 if best_rank <= 30 else 0.2 + expected_change = min(expected_change, 6.5) + + volume_ratio = _number(row.get("volume_ratio")) + turnover_rate = _number(row.get("turnover_rate")) + amount_million = _number(row.get("amount_million")) + confirmation = self._auction_confirmation(row) + actual_strength = _number(row.get("change")) + confirmation + label = self._expectation_label(actual_strength, expected_change) + is_broken = "昨日炸板" in candidate["sources"] and "昨日涨停" not in candidate["sources"] + identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak == 1 else "昨日炸板" if is_broken else "人气榜标的" + popularity = ",双榜共识" if len(ranks) == 2 else ",热榜靠前" if ranks and min(ranks) <= 10 else "" + difference = _number(row.get("change")) - expected_change + direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合" + reason = ( + f"{identity}{popularity};竞价涨幅{direction}预期中枢" + f"{abs(difference):.1f}个百分点,量比{volume_ratio:.2f}" + ) + tags = sorted(core_tags.get(str(row.get("code") or ""), set())) + scored_row = { + **row, + "concepts": candidate["concepts"], + } + attention_score = self._attention_score( + scored_row, + expected_change, + tags, + candidate["sources"], + streak, + str(candidate.get("sector") or row.get("sector") or "") in strong_sector_names, + ) + normalized.append( + { + **scored_row, + "sector": candidate["sector"] if candidate["sector"] != "其他" else row.get("sector", "其他"), + "candidate_sources": candidate["sources"], + "source_label": " · ".join(candidate["sources"]), + "prior_streak": streak, + "concepts": candidate["concepts"], + "expected_change": round(expected_change, 2), + "actual_strength": round(actual_strength, 2), + "expectation": label, + "attention_score": attention_score, + "core_tags": tags, + "is_market_core": bool(tags), + "expectation_reason": reason, + } + ) + normalized.sort(key=lambda item: (_number(item.get("attention_score")), _number(item.get("amount_million"))), reverse=True) + matched_top = { + str(item.get("code") or "") + for item in sorted( + (item for item in normalized if item.get("expectation") == "符合预期"), + key=lambda item: _number(item.get("attention_score")), + reverse=True, + )[:20] + } + focus_candidates = [ + item for item in normalized + if item.get("is_market_core") + or (_number(item.get("attention_score")) >= 55 and item.get("expectation") != "符合预期") + or str(item.get("code") or "") in matched_top + ] + mandatory = [item for item in focus_candidates if item.get("is_market_core")] + mandatory_codes = {str(item.get("code") or "") for item in mandatory} + optional = [item for item in focus_candidates if str(item.get("code") or "") not in mandatory_codes] + focus_rows = sorted(mandatory, key=lambda item: _number(item.get("attention_score")), reverse=True) + focus_rows.extend(optional[:max(0, 30 - len(focus_rows))]) + focus_rows.sort(key=lambda item: _number(item.get("attention_score")), reverse=True) + return normalized, { + "baseline_date": _display_date(baseline_date), + "prior_limit_count": len(prior_limits), + "prior_broken_count": len(prior_broken), + "hot_candidate_count": sum( + any(source in {"同花顺热榜", "东方财富热榜"} for source in item["sources"]) + for item in candidates.values() + ), + "core_count": sum(bool(item.get("is_market_core")) for item in normalized), + "notice": ";".join(errors), + }, focus_rows + + @staticmethod + def _auction_theme_evidence( + prior_snapshot: dict[str, Any], + candidate_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 "其他") + matched = [row for row in candidate_rows if str(row.get("sector") or "其他") == name] + changes = [_number(row.get("change")) for row in matched] + middle = median(changes) if changes else -10.0 + positive_rate = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0.0 + if middle >= 2 and positive_rate >= 60: + status = "强承接" + elif middle >= 0 and positive_rate >= 50: + status = "有承接" + elif middle > -2: + status = "分歧" + else: + status = "承接弱" + carry.append( + { + "name": name, + "status": status, + "prior_limit_count": int(_number(sector.get("count"))), + "leader": str(sector.get("leader") or "--"), + "matched_count": len(matched), + "median_change": round(middle, 2) if matched else None, + "positive_rate": round(positive_rate, 1), + "amount_million": round(sum(_number(row.get("amount_million")) for row in matched), 2), + } + ) + + concept_groups: dict[str, list[dict[str, Any]]] = {} + prior_names = {str(item.get("name") or "") for item in prior_sectors} + for row in candidate_rows: + for concept in row.get("concepts") or []: + if concept and concept not in prior_names: + concept_groups.setdefault(str(concept), []).append(row) + new_themes = [] + for name, members in concept_groups.items(): + unique = {str(item.get("code") or ""): item for item in members} + values = list(unique.values()) + changes = [_number(item.get("change")) for item in values] + if len(values) < 2 or median(changes) < 2 or sum(value > 0.2 for value in changes) / len(values) < 0.67: + continue + new_themes.append( + { + "name": name, + "stock_count": len(values), + "median_change": round(median(changes), 2), + "amount_million": round(sum(_number(item.get("amount_million")) for item in values), 2), + "leaders": [str(item.get("name") or "--") for item in sorted(values, key=lambda value: _number(value.get("change")), reverse=True)[:3]], + } + ) + new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"], item["amount_million"]), reverse=True) + return {"carry": carry, "new_themes": new_themes[:8]} + + def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]: + dates = self.database.auction_factor_dates(trade_date, 10) + stock_list_dates = { + str(item.get("ts_code") or ""): str(item.get("list_date") or "") + for item in self.database.list_stock_master() + if item.get("ts_code") + } + history = [] + for current_date in dates: + rows = [ + row for row in self.database.auction_factors_for_date(current_date) + if ( + str(row.get("ts_code") or "") in stock_list_dates + and ( + not stock_list_dates[str(row.get("ts_code") or "")] + or stock_list_dates[str(row.get("ts_code") or "")] < current_date + ) + ) + ] + history.append( + { + "trade_date": _display_date(current_date), + "amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2), + "stock_count": len(rows), + } + ) + return history + + def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None: + existing = set(self.database.auction_factor_dates(trade_date, target_days + 5)) + if len(existing) >= target_days: + return + end = datetime.strptime(trade_date, "%Y%m%d") + start = (end - timedelta(days=35)).strftime("%Y%m%d") + try: + calendar = self.client.query( + "trade_cal", + { + "exchange": "SSE", + "start_date": start, + "end_date": trade_date, + "is_open": 1, + }, + "cal_date,is_open", + ) + except TushareError: + return + dates = sorted( + str(item.get("cal_date") or "") + for item in calendar + if int(_number(item.get("is_open"))) == 1 and item.get("cal_date") + )[-target_days:] + for current_date in dates: + if current_date in existing: + continue + try: + rows = self.client.query( + "stk_auction", + {"trade_date": current_date}, + "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share", + ) + except TushareError: + break + if rows: + self.database.upsert_auction_factors(rows) + existing.add(current_date) + + def _with_auction_watchlist( + self, + result: dict[str, Any], + trade_date: str, + user_id: int, + ) -> dict[str, Any]: + personalized = copy.deepcopy(result) + if not user_id: + personalized["watchlist_rows"] = [] + personalized["watchlist_missing_count"] = 0 + return personalized + watched = self.database.list_watchlist(user_id) + if not watched: + personalized["watchlist_rows"] = [] + personalized["watchlist_missing_count"] = 0 + return personalized + + public_rows = { + str(item.get("code") or ""): item + for item in ( + list(personalized.get("rows") or []) + + list(personalized.get("one_price_rows") or []) + ) + } + factors = { + str(item.get("ts_code") or "").split(".")[0]: item + for item in self.database.auction_factors_for_date(trade_date) + } + master = { + str(item.get("ts_code") or "").split(".")[0]: item + for item in self.database.list_stock_master() + } + rows = [] + missing = 0 + for item in watched: + code = str(item.get("code") or "") + if code in public_rows: + rows.append({**public_rows[code], "is_watchlist": True}) + continue + factor = factors.get(code) + if not factor: + missing += 1 + rows.append( + { + "code": code, + "name": str(item.get("name") or "--"), + "sector": str(item.get("sector") or "其他"), + "available": False, + "is_watchlist": True, + } + ) + continue + stock = master.get(code, {}) + price = _number(factor.get("price")) + pre_close = _number(factor.get("pre_close")) + change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0 + row = { + "code": code, + "ts_code": str(factor.get("ts_code") or ""), + "name": str(item.get("name") or stock.get("name") or "--"), + "sector": str(item.get("sector") or stock.get("industry") or "其他"), + "price": round(price, 2), + "pre_close": round(pre_close, 2), + "change": round(change, 2), + "amount_million": round(_number(factor.get("amount")) / 1_000_000, 2), + "turnover_rate": round(_number(factor.get("turnover_rate")), 4), + "volume_ratio": round(_number(factor.get("volume_ratio")), 2), + "candidate_sources": ["我的自选"], + "source_label": "我的自选", + "prior_streak": 0, + "concepts": [], + "expected_change": 0.0, + "core_tags": [], + "is_market_core": False, + "is_watchlist": True, + "available": True, + } + actual_strength = change + self._auction_confirmation(row) + row["actual_strength"] = round(actual_strength, 2) + row["expectation"] = self._expectation_label(actual_strength, 0.0) + row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False) + direction = "高于" if change > 0 else "低于" if change < 0 else "贴合" + row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}" + rows.append(row) + rows.sort( + key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))), + reverse=True, + ) + personalized["watchlist_rows"] = rows + personalized["watchlist_missing_count"] = missing + return personalized + + def _dynamic_auction_rows( + self, + trade_date: str, + baseline_date: str, + user_id: int, + ) -> list[dict[str, Any]]: + if not self.ifind or not self.ifind.configured: + return [] + master = self._stock_master() + placeholders = [ + { + "code": str(item.get("code") or ts_code.split(".")[0]), + "ts_code": ts_code, + "name": str(item.get("name") or "--"), + "sector": str(item.get("industry") or "其他"), + } + for ts_code, item in master.items() + ] + candidates, _, _ = self._auction_candidates(placeholders, baseline_date) + selected_codes = { + str(item.get("ts_code") or "") + for item in candidates + if item.get("ts_code") + } + if user_id: + watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)} + selected_codes.update( + ts_code for ts_code in master if ts_code.split(".")[0] in watched + ) + selected_codes.discard("") + if not selected_codes: + return [] + + display_date = _display_date(trade_date) + now = self._now_provider() + if now.tzinfo is None: + now = now.replace(tzinfo=CHINA_TIMEZONE) + else: + now = now.astimezone(CHINA_TIMEZONE) + end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25)) + end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}" + start_stamp = f"{display_date} 09:15:00" + snapshot_rows: list[dict[str, Any]] = [] + ordered_codes = sorted(selected_codes) + for index in range(0, len(ordered_codes), 80): + try: + snapshot_rows.extend( + self.ifind.snapshots( + ordered_codes[index:index + 80], + [ + "latest", "volume", "amount", "preClose", + "bid1", "bidSize1", "ask1", "askSize1", + ], + start_stamp, + end_stamp, + cache_ttl=8, + ) + ) + except IfindError: + continue + + latest: dict[str, dict[str, Any]] = {} + for row in snapshot_rows: + ts_code = str(row.get("thscode") or "") + previous = latest.get(ts_code) or {} + if ( + ts_code + and _number(row.get("latest")) > 0 + and str(row.get("time") or "") >= str(previous.get("time") or "") + ): + latest[ts_code] = row + prior_factors = { + str(item.get("ts_code") or ""): item + for item in self.database.auction_factors_for_date(baseline_date) + } + normalized = [] + for ts_code, row in latest.items(): + price = _number(row.get("latest")) + pre_close = _number(row.get("preClose")) + volume = _number(row.get("volume")) + bid_size = _number(row.get("bidSize1")) + ask_size = _number(row.get("askSize1")) + if volume <= 0 and bid_size > 0 and ask_size > 0: + volume = min(bid_size, ask_size) + amount = _number(row.get("amount")) + if amount <= 0 and price > 0 and volume > 0: + amount = price * volume + prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol")) + normalized.append( + { + "ts_code": ts_code, + "trade_date": trade_date, + "vol": volume, + "price": price, + "amount": amount, + "pre_close": pre_close, + "turnover_rate": 0, + "volume_ratio": volume / prior_volume if prior_volume > 0 else 0, + "float_share": 0, + "bid_size1": bid_size, + "ask_size1": ask_size, + "snapshot_time": str(row.get("time") or ""), + "dynamic": True, + } + ) + return normalized + + def auction_center( + self, + requested_date: str, + force: bool = False, + user_id: int = 0, + ) -> dict[str, Any]: + trade_date, previous_date = self._trade_context(requested_date) + session = self._auction_session(requested_date, trade_date) + phase = str(session["phase"]) + ifind_ready = bool(self.ifind and self.ifind.configured) + live_dynamic = phase == "observing" and ifind_ready + use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready + data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date + carried_forward = data_date != trade_date + cache_key = data_date + if not force and not live_dynamic: + cached = self.database.get_data_snapshot("auction_center_v6", cache_key) + if cached: + result = copy.deepcopy(cached) + result["meta"] = { + **result.get("meta", {}), + **session, + "requested_date": _display_date(requested_date), + "trade_date": _display_date(data_date), + "carried_forward": carried_forward, + "available": bool((result.get("summary") or {}).get("stock_count")), + "cached": True, + } + return self._with_auction_watchlist(result, data_date, user_id) + + if use_ifind_snapshot: + rows = self._dynamic_auction_rows(data_date, previous_date, user_id) + else: + rows = [] + if not rows and not live_dynamic: + try: + rows = self.client.query("stk_auction", {"trade_date": data_date}) + except TushareError: + rows = self.database.auction_factors_for_date(data_date) + if not rows: + return { + "meta": { + **session, + "requested_date": _display_date(requested_date), + "trade_date": _display_date(data_date), + "carried_forward": carried_forward, + "available": False, + "cached": False, + "notice": "该交易日暂无可用竞价快照", + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + }, + "summary": { + "stock_count": 0, "up_count": 0, "down_count": 0, + "limit_open_count": 0, "strong_open_count": 0, + "median_change": 0, "amount_billion": 0, + "candidate_count": 0, "focus_count": 0, "one_price_count": 0, + }, + "expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0}, + "candidate_meta": {"baseline_date": _display_date(previous_date)}, + "themes": {"carry": [], "new_themes": []}, + "amount_history": self._auction_amount_history(data_date), + "news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"}, + "focus_rows": [], "one_price_rows": [], "rows": [], + "watchlist_rows": [], "watchlist_missing_count": 0, + } + + master = self._stock_master() + try: + limit_rows = self.client.query( + "stk_limit", + {"trade_date": data_date}, + "trade_date,ts_code,up_limit,down_limit", + ) + except TushareError: + limit_rows = [] + limit_map = {str(item.get("ts_code") or ""): item for item in limit_rows} + normalized = [] + for row in rows: + ts_code = str(row.get("ts_code") or "") + stock = master.get(ts_code) + price = _number(row.get("price")) + pre_close = _number(row.get("pre_close")) + list_date = str((stock or {}).get("list_date") or "") + if ( + not stock + or price <= 0 + or pre_close <= 0 + or (list_date and list_date >= data_date) + ): + continue + change = (price / pre_close - 1) * 100 + amount_million = _number(row.get("amount")) / 1_000_000 + volume_ratio = _number(row.get("volume_ratio")) + turnover_rate = _number(row.get("turnover_rate")) + up_limit = _number((limit_map.get(ts_code) or {}).get("up_limit")) + is_one_price = bool( + up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005) + ) + normalized.append( + { + "code": str(stock.get("code") or ts_code.split(".")[0]), + "ts_code": ts_code, + "name": str(stock.get("name") or "--"), + "sector": str(stock.get("industry") or "其他"), + "price": round(price, 2), + "pre_close": round(pre_close, 2), + "change": round(change, 2), + "volume_ten_thousand": round(_number(row.get("vol")) / 10_000, 2), + "amount_million": round(amount_million, 2), + "turnover_rate": round(turnover_rate, 4), + "volume_ratio": round(volume_ratio, 2), + "up_limit": round(up_limit, 2) if up_limit else None, + "is_one_price": is_one_price, + "signal": ( + "竞价涨停" if change >= 9.5 else + "强势高开" if change >= 3 else + "高开" if change > 0.2 else + "深度低开" if change <= -3 else + "低开" if change < -0.2 else "平开" + ), + } + ) + normalized.sort(key=lambda item: (item["amount_million"], item["volume_ratio"]), reverse=True) + self.database.upsert_auction_factors(rows) + changes = [item["change"] for item in normalized] + total = len(normalized) + _, baseline_date = self._trade_context(data_date) + candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date) + candidate_map = {str(item.get("code") or ""): item for item in candidates} + one_price_rows = [] + for row in normalized: + if not row.get("is_one_price"): + continue + enriched = candidate_map.get(str(row.get("code") or ""), {}) + one_price_rows.append( + { + **row, + **enriched, + "attention_score": None, + "expectation": "", + "expected_change": None, + "expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离", + } + ) + one_price_codes = {str(item.get("code") or "") for item in one_price_rows} + candidates = [item for item in candidates if str(item.get("code") or "") not in one_price_codes] + focus_rows = [item for item in focus_rows if str(item.get("code") or "") 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, + ) + expectations = { + label: sum(item.get("expectation") == label for item in candidates) + for label in ("超预期", "符合预期", "低于预期") + } + prior_snapshot = self.database.get_snapshot(baseline_date) or {} + themes = self._auction_theme_evidence(prior_snapshot, candidates + one_price_rows) + self._ensure_auction_amount_history(data_date) + amount_history = self._auction_amount_history(data_date) + prior_amounts = [item["amount_billion"] for item in amount_history[:-1]] + current_amount = round(sum(item["amount_million"] for item in normalized) / 100, 2) + previous_amount = prior_amounts[-1] if prior_amounts else 0 + five_day_amounts = prior_amounts[-5:] + five_day_average = sum(five_day_amounts) / len(five_day_amounts) if five_day_amounts else 0 + result = { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(data_date), + "carried_forward": carried_forward, + "available": bool(normalized), + **session, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + }, + "summary": { + "stock_count": total, + "up_count": sum(value > 0.2 for value in changes), + "down_count": sum(value < -0.2 for value in changes), + "limit_open_count": len(one_price_rows), + "strong_open_count": sum(value >= 3 for value in changes), + "median_change": round(median(changes), 2) if changes else 0, + "amount_billion": current_amount, + "amount_change_previous": round((current_amount / previous_amount - 1) * 100, 1) if previous_amount else None, + "amount_change_5d": round((current_amount / five_day_average - 1) * 100, 1) if five_day_average else None, + "candidate_count": len(candidates), + "focus_count": len(focus_rows), + "one_price_count": len(one_price_rows), + }, + "expectations": expectations, + "candidate_meta": candidate_meta, + "themes": themes, + "amount_history": amount_history, + "news_feedback": { + "available": False, + "message": "隔夜消息反馈暂不可用", + "detail": "待稳定的新闻与公告数据接入后开放", + }, + "focus_rows": focus_rows, + "one_price_rows": one_price_rows, + "rows": candidates, + } + if not live_dynamic: + self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result) + return self._with_auction_watchlist(result, data_date, user_id) + + def _theme_directory(self) -> list[dict[str, Any]]: + cached = self.database.get_data_snapshot("theme_directory_v1", "ths") or {} + if cached.get("items"): + return list(cached["items"]) + rows = self.client.query( + "ths_index", {}, "ts_code,name,count,exchange,list_date,type" + ) + items = [ + { + "code": str(row.get("ts_code") or ""), + "name": str(row.get("name") or ""), + "member_count": int(_number(row.get("count"))), + "list_date": str(row.get("list_date") or ""), + } + for row in rows + if str(row.get("type") or "").upper() == "N" + and str(row.get("exchange") or "").upper() == "A" + and row.get("ts_code") + and row.get("name") + ] + self.database.save_data_snapshot( + "theme_directory_v1", "ths", "market", {"items": items} + ) + return items + + def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]: + trade_date, previous_date = self._trade_context(requested_date) + if not force: + cached = self.database.get_data_snapshot("theme_library_v1", trade_date) + if cached: + result = copy.deepcopy(cached) + result["meta"] = {**result.get("meta", {}), "cached": True} + return result + + try: + daily = self.client.query( + "ths_daily", + {"trade_date": trade_date}, + "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate", + ) + except TushareError: + fallback = self._latest_feature_snapshot("theme_library_v1", trade_date) + if fallback: + result = copy.deepcopy(fallback) + result["meta"] = { + **result.get("meta", {}), + "requested_date": _display_date(requested_date), + "carried_forward": True, + "cached": True, + "notice": "当前题材行情暂不可用,展示最近有效快照", + } + return result + daily = [] + actual_date = trade_date + carried_forward = False + if not daily and previous_date: + try: + daily = self.client.query( + "ths_daily", + {"trade_date": previous_date}, + "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate", + ) + except TushareError: + daily = [] + actual_date = previous_date + carried_forward = bool(daily) + daily_map = {str(row.get("ts_code") or ""): row for row in daily} + try: + hot_rows = self.client.query("ths_hot", {"trade_date": actual_date}) + except TushareError: + hot_rows = [] + hot_map = { + str(row.get("ts_code") or ""): int(_number(row.get("rank"))) + for row in hot_rows + if str(row.get("data_type") or "") == "概念板块" + } + items = [] + for item in self._theme_directory(): + quote = daily_map.get(item["code"], {}) + items.append( + { + **item, + "change": round(_number(quote.get("pct_change")), 2), + "close": round(_number(quote.get("close")), 3), + "turnover_rate": round(_number(quote.get("turnover_rate")), 2), + "volume": round(_number(quote.get("vol")), 2), + "hot_rank": hot_map.get(item["code"]), + "has_quote": bool(quote), + } + ) + items.sort( + key=lambda item: ( + item["has_quote"], + item["hot_rank"] is not None, + -(item["hot_rank"] or 9999), + item["change"], + ), + reverse=True, + ) + quoted = [item for item in items if item["has_quote"]] + result = { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(actual_date), + "carried_forward": carried_forward, + "cached": False, + "notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录", + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + }, + "summary": { + "theme_count": len(items), + "quoted_count": len(quoted), + "up_count": sum(item["change"] > 0 for item in quoted), + "down_count": sum(item["change"] < 0 for item in quoted), + "hot_count": len(hot_map), + }, + "items": items, + } + self.database.save_data_snapshot("theme_library_v1", trade_date, "market", result) + return result + + def theme_detail(self, code: str, requested_date: str) -> dict[str, Any]: + code = str(code or "").strip().upper() + library = self.theme_library(requested_date) + theme = next((item for item in library["items"] if item["code"] == code), None) + if not theme: + raise ValueError("未找到对应题材。") + actual_date = str(library["meta"]["trade_date"]).replace("-", "") + detail_key = f"{actual_date}:{code}" + cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key) + if cached_detail: + return cached_detail + try: + members = self.client.query( + "ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name" + ) + except TushareError: + members = [] + bars = self.database.daily_bars_for_date(actual_date) + if not bars: + bars = self.client.query( + "daily", + {"trade_date": actual_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + self.database.upsert_daily_bars(bars) + bar_map = {str(row.get("ts_code") or ""): row for row in bars} + normalized_members = [] + for member in members: + ts_code = str(member.get("con_code") or "") + quote = bar_map.get(ts_code, {}) + normalized_members.append( + { + "code": ts_code.split(".")[0], + "ts_code": ts_code, + "name": str(member.get("con_name") or "--"), + "price": round(_number(quote.get("close")), 2), + "change": round(_number(quote.get("pct_chg")), 2), + "amount_billion": round(_number(quote.get("amount")) / 100_000, 2), + "has_quote": bool(quote), + } + ) + normalized_members.sort( + key=lambda item: (item["has_quote"], item["change"], item["amount_billion"]), + reverse=True, + ) + end = datetime.strptime(actual_date, "%Y%m%d") + try: + history = self.client.query( + "ths_daily", + { + "ts_code": code, + "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"), + "end_date": actual_date, + }, + "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate", + ) + except TushareError: + history = [] + history.sort(key=lambda row: str(row.get("trade_date") or "")) + series = [ + { + "trade_date": _display_date(str(row.get("trade_date") or "")), + "open": _number(row.get("open")), + "high": _number(row.get("high")), + "low": _number(row.get("low")), + "close": _number(row.get("close")), + "change": _number(row.get("pct_change")), + "volume": _number(row.get("vol")), + } + for row in history[-90:] + ] + result = { + "meta": { + "trade_date": _display_date(actual_date), + "notice": "" if members or history else "题材成分与走势暂不可用", + }, + "theme": theme, + "series": series, + "members": normalized_members, + "summary": { + "member_count": len(normalized_members), + "up_count": sum(item["change"] > 0 for item in normalized_members if item["has_quote"]), + "down_count": sum(item["change"] < 0 for item in normalized_members if item["has_quote"]), + "quoted_count": sum(item["has_quote"] for item in normalized_members), + }, + } + if members or history: + self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result) + return result + + @staticmethod + def _parse_concepts(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item) 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) for item in parsed if str(item).strip()] + except json.JSONDecodeError: + pass + return [part.strip() for part in text.split(",") if part.strip()] + + def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]: + trade_date, previous_date = self._trade_context(requested_date) + if not force: + cached = self.database.get_data_snapshot("popularity_v1", trade_date) + if cached: + result = copy.deepcopy(cached) + result["meta"] = {**result.get("meta", {}), "cached": True} + return result + + ths_rows, dc_rows, errors = self._hot_rows(trade_date) + actual_date = trade_date + carried_forward = False + if not ths_rows and not dc_rows and previous_date: + ths_rows, dc_rows, errors = self._hot_rows(previous_date) + actual_date = previous_date + carried_forward = bool(ths_rows or dc_rows) + if not ths_rows and not dc_rows: + fallback = self._latest_feature_snapshot("popularity_v1", trade_date) + if fallback: + result = copy.deepcopy(fallback) + result["meta"] = { + **result.get("meta", {}), + "requested_date": _display_date(requested_date), + "carried_forward": True, + "cached": True, + "notice": "当前榜单暂不可用,展示最近有效快照", + } + return result + return { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(trade_date), + "previous_trade_date": _display_date(previous_date), + "carried_forward": False, + "cached": False, + "notice": "该交易日暂无可用人气榜", + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + }, + "summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0}, + "combined": [], "ths": [], "dc": [], + } + + prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d") + prior_date, _ = self._trade_context(prior_request) + previous_ths, previous_dc, _ = self._hot_rows(prior_date) + ths = self._normalize_hot(ths_rows, "热股", previous_ths) + dc = self._normalize_hot(dc_rows, "A股市场", previous_dc) + ths_map = {item["ts_code"]: item for item in ths} + dc_map = {item["ts_code"]: item for item in dc} + combined = [] + for ts_code in set(ths_map) | set(dc_map): + ths_item = ths_map.get(ts_code) + dc_item = dc_map.get(ts_code) + 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": (ths_item or {}).get("concepts") or [], + } + ) + combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True) + for index, item in enumerate(combined, 1): + item["rank"] = index + result = { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(actual_date), + "previous_trade_date": _display_date(prior_date), + "carried_forward": carried_forward, + "cached": False, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": ";".join(errors), + }, + "summary": { + "ths_count": len(ths), + "dc_count": len(dc), + "dual_count": sum(item["dual_source"] for item in combined), + }, + "combined": combined[:200], + "ths": ths, + "dc": dc, + } + self.database.save_data_snapshot("popularity_v1", trade_date, "market", result) + return result + + def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]: + errors = [] + try: + ths = self.client.query("ths_hot", {"trade_date": trade_date}) + except TushareError: + ths = [] + errors.append("同花顺榜单暂不可用") + try: + dc = self.client.query("dc_hot", {"trade_date": trade_date}) + except TushareError: + dc = [] + errors.append("东方财富榜单暂不可用") + return ths, dc, errors + + def _normalize_hot( + self, + rows: list[dict[str, Any]], + data_type: str, + previous_rows: list[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 + rank = int(_number(row.get("rank"))) + ts_code = str(row.get("ts_code") or "") + prior_rank = previous.get(ts_code) + items.append( + { + "rank": rank, + "ts_code": ts_code, + "code": ts_code.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 - rank) if prior_rank else None, + "concepts": self._parse_concepts(row.get("concept")), + "reason": str(row.get("rank_reason") or ""), + "rank_time": str(row.get("rank_time") or ""), + } + ) + items.sort(key=lambda item: item["rank"]) + return items diff --git a/app/backend/features/market/service.py b/app/backend/features/market/service.py index f4e29db..935037e 100644 --- a/app/backend/features/market/service.py +++ b/app/backend/features/market/service.py @@ -14,6 +14,7 @@ from backend.bootstrap.config import ( from backend.data.providers.ifind_client import IfindError from backend.data.providers.tushare_client import TushareClient, TushareError from backend.features.market.charts import ChartDataError +from backend.features.market.insights import MarketInsightsService from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION @@ -36,6 +37,14 @@ THS_SEARCH_TYPES = { class MarketServiceMixin: + def _market_insights(self) -> MarketInsightsService: + if not self.configured: + raise ValueError("行情数据尚未配置。") + return MarketInsightsService( + self.database, + self._tushare_client(), + ifind=self.ifind, + ) def _tushare_client(self) -> TushareClient: gateway = getattr(self, "data_gateway", None) if gateway is not None: diff --git a/app/backend/features/popularity/__init__.py b/app/backend/features/popularity/__init__.py new file mode 100644 index 0000000..049ea59 --- /dev/null +++ b/app/backend/features/popularity/__init__.py @@ -0,0 +1,4 @@ +from .repository import PopularityRepositoryMixin +from .service import PopularityServiceMixin + +__all__ = ["PopularityRepositoryMixin", "PopularityServiceMixin"] diff --git a/app/backend/features/popularity/repository.py b/app/backend/features/popularity/repository.py new file mode 100644 index 0000000..52682e8 --- /dev/null +++ b/app/backend/features/popularity/repository.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + + +class PopularityRepositoryMixin: + def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("trade_date") or ""), + str(row.get("ts_code") or ""), + int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None, + int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None, + float(row.get("combined_score") or 0), + int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None, + int(bool(row.get("dual_source"))), + ) + for row in rows + if row.get("trade_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO popularity_factors + (trade_date, ts_code, ths_rank, dc_rank, combined_score, + rank_change, dual_source) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + ths_rank=excluded.ths_rank, + dc_rank=excluded.dc_rank, + combined_score=excluded.combined_score, + rank_change=excluded.rank_change, + dual_source=excluded.dual_source + """, + values, + ) + return len(values) diff --git a/app/backend/features/popularity/service.py b/app/backend/features/popularity/service.py new file mode 100644 index 0000000..7380237 --- /dev/null +++ b/app/backend/features/popularity/service.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + +from backend.bootstrap.config import normalize_date +from backend.features.market.insights import MarketInsightsService + + +class PopularityServiceMixin: + def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]: + return self._market_insights().popularity(normalize_date(trade_date), force) diff --git a/app/backend/features/themes/__init__.py b/app/backend/features/themes/__init__.py new file mode 100644 index 0000000..6609ed1 --- /dev/null +++ b/app/backend/features/themes/__init__.py @@ -0,0 +1,3 @@ +from .service import ThemeServiceMixin + +__all__ = ["ThemeServiceMixin"] diff --git a/app/backend/features/themes/service.py b/app/backend/features/themes/service.py new file mode 100644 index 0000000..2647c69 --- /dev/null +++ b/app/backend/features/themes/service.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from backend.bootstrap.config import normalize_date +from backend.features.market.insights import MarketInsightsService + + +class ThemeServiceMixin: + def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]: + return self._market_insights().theme_library(normalize_date(trade_date), force) + + def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]: + return self._market_insights().theme_detail(code, normalize_date(trade_date)) diff --git a/app/database.py b/app/database.py index 8fe4266..35896fc 100644 --- a/app/database.py +++ b/app/database.py @@ -8,8 +8,11 @@ from typing import Any from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory from backend.features.accounts.repository import AccountRepositoryMixin +from backend.features.auction.repository import AuctionRepositoryMixin +from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin from backend.features.market.repository import MarketRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin +from backend.features.popularity.repository import PopularityRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin @@ -24,8 +27,11 @@ def _optional_float(value: Any) -> float | None: class ReviewDatabase( AccountRepositoryMixin, + AuctionRepositoryMixin, + DragonTigerRepositoryMixin, MarketRepositoryMixin, PoolRepositoryMixin, + PopularityRepositoryMixin, SystemSettingsRepositoryMixin, ): def __init__(self, path: Path) -> None: @@ -839,24 +845,6 @@ class ReviewDatabase( return cursor.rowcount > 0 - def list_seat_aliases(self) -> dict[str, str]: - with self.connect() as connection: - rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall() - return {row["seat_name"]: row["alias"] for row in rows} - - def save_seat_alias(self, seat_name: str, alias: str) -> None: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - INSERT INTO seat_aliases (seat_name, alias, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(seat_name) DO UPDATE SET - alias = excluded.alias, - updated_at = excluded.updated_at - """, - (seat_name, alias, now), - ) def list_sector_phase_overrides(self) -> dict[str, str]: with self.connect() as connection: @@ -1056,44 +1044,6 @@ class ReviewDatabase( ) return len(values) - def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int: - values = [] - for row in rows: - trade_date = str(row.get("trade_date") or "") - ts_code = str(row.get("ts_code") or "") - price = float(row.get("price") or 0) - pre_close = float(row.get("pre_close") or 0) - if not trade_date or not ts_code or price <= 0 or pre_close <= 0: - continue - values.append( - ( - trade_date, - ts_code, - price, - pre_close, - (price / pre_close - 1) * 100, - float(row.get("vol") or 0), - float(row.get("amount") or 0), - float(row.get("turnover_rate") or 0), - float(row.get("volume_ratio") or 0), - ) - ) - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO auction_factors - (trade_date, ts_code, price, pre_close, change, vol, amount, - turnover_rate, volume_ratio) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - price=excluded.price, pre_close=excluded.pre_close, - change=excluded.change, vol=excluded.vol, amount=excluded.amount, - turnover_rate=excluded.turnover_rate, - volume_ratio=excluded.volume_ratio - """, - values, - ) - return len(values) def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int: values = [ @@ -1130,84 +1080,7 @@ class ReviewDatabase( ) return len(values) - def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("trade_date") or ""), - str(row.get("ts_code") or ""), - int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None, - int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None, - float(row.get("combined_score") or 0), - int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None, - int(bool(row.get("dual_source"))), - ) - for row in rows - if row.get("trade_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO popularity_factors - (trade_date, ts_code, ths_rank, dc_rank, combined_score, - rank_change, dual_source) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - ths_rank=excluded.ths_rank, - dc_rank=excluded.dc_rank, - combined_score=excluded.combined_score, - rank_change=excluded.rank_change, - dual_source=excluded.dual_source - """, - values, - ) - return len(values) - def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int: - grouped: dict[tuple[str, str], dict[str, float | int]] = {} - for row in rows: - trade_date = str(row.get("trade_date") or "") - ts_code = str(row.get("ts_code") or "") - seat_name = str(row.get("exalter") or row.get("seat_name") or "") - if not trade_date or not ts_code or "机构专用" not in seat_name: - continue - group = grouped.setdefault( - (trade_date, ts_code), - {"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0}, - ) - group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0) - group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0) - group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0) - group["seats"] = int(group["seats"]) + 1 - values = [ - (trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"]) - for (trade_date, ts_code), item in grouped.items() - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO lhb_institution_daily - (trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - net_buy_amount=excluded.net_buy_amount, - buy_amount=excluded.buy_amount, - sell_amount=excluded.sell_amount, - seat_count=excluded.seat_count - """, - values, - ) - return len(values) - - def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: - where = "WHERE trade_date <= ?" if end_date else "" - parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) - with self.connect() as connection: - rows = connection.execute( - f"SELECT DISTINCT trade_date FROM auction_factors {where} " - "ORDER BY trade_date DESC LIMIT ?", - parameters, - ).fetchall() - return [row["trade_date"] for row in reversed(rows)] def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]: where = "WHERE trade_date <= ?" if end_date else "" @@ -1227,13 +1100,6 @@ class ReviewDatabase( ).fetchall() return [str(row["end_date"]) for row in rows] - def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - "SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code", - (trade_date,), - ).fetchall() - return [dict(row) for row in rows] def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]: with self.connect() as connection: diff --git a/app/market_insights.py b/app/market_insights.py index 8ee5dad..33ca0c0 100644 --- a/app/market_insights.py +++ b/app/market_insights.py @@ -1,1312 +1 @@ -from __future__ import annotations - -import copy -import json -from datetime import datetime, time as dt_time, timedelta, timezone -from statistics import median -from typing import Any, Callable - -from database import ReviewDatabase -from ifind_client import IfindError, IfindHttpClient -from tushare_client import TushareClient, TushareError - - -CHINA_TIMEZONE = timezone(timedelta(hours=8)) - - -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 - - -def _display_date(value: str) -> str: - text = str(value or "").replace("-", "") - if len(text) != 8: - return str(value or "") - return f"{text[:4]}-{text[4:6]}-{text[6:]}" - - -class MarketInsightsService: - """Read-only market features backed by Tushare and shared SQLite caches.""" - - def __init__( - self, - database: ReviewDatabase, - client: TushareClient, - now_provider: Callable[[], datetime] | None = None, - ifind: IfindHttpClient | None = None, - ) -> None: - self.database = database - self.client = client - self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE)) - self.ifind = ifind - - def _trade_context(self, requested_date: str) -> tuple[str, str]: - """Resolve trading dates without making cached feature pages depend on Tushare uptime.""" - requested = str(requested_date or "").replace("-", "") - try: - return self.client.resolve_trade_context(requested) - except TushareError: - latest = self.database.get_latest_real_snapshot(requested) or {} - trade_date = str( - (latest.get("meta") or {}).get("trade_date") - or latest.get("_snapshot_date") - or requested - ).replace("-", "") - previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {} - previous_date = str( - (previous.get("meta") or {}).get("trade_date") - or previous.get("_snapshot_date") - or "" - ).replace("-", "") - return trade_date, previous_date - - def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None: - return self.database.get_latest_data_snapshot(kind, "", trade_date) - - def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]: - now = self._now_provider() - if now.tzinfo is None: - now = now.replace(tzinfo=CHINA_TIMEZONE) - else: - now = now.astimezone(CHINA_TIMEZONE) - requested = str(requested_date or "").replace("-", "") - today = now.strftime("%Y%m%d") - if requested != today or trade_date != today: - return { - "phase": "archive", - "actionable": False, - "next_transition_at": "", - } - - local_time = now.time().replace(tzinfo=None) - transitions = ( - (dt_time(9, 15), "pending", dt_time(9, 15)), - (dt_time(9, 25), "observing", dt_time(9, 25)), - (dt_time(9, 30), "selection", dt_time(9, 30)), - ) - for boundary, phase, next_boundary in transitions: - if local_time < boundary: - transition = now.replace( - hour=next_boundary.hour, - minute=next_boundary.minute, - second=0, - microsecond=0, - ) - return { - "phase": phase, - "actionable": phase == "selection", - "next_transition_at": transition.isoformat(timespec="seconds"), - } - return { - "phase": "finalized", - "actionable": False, - "next_transition_at": "", - } - - def _stock_master(self) -> dict[str, dict[str, Any]]: - rows = self.database.list_stock_master() - if not rows: - rows = self.client.query( - "stock_basic", - {"list_status": "L"}, - "ts_code,name,industry,market,list_date", - ) - self.database.upsert_stock_master(rows) - rows = self.database.list_stock_master() - return {str(row.get("ts_code") or ""): row for row in rows} - - @staticmethod - def _expectation_label(actual_strength: float, expected_change: float) -> str: - difference = actual_strength - expected_change - if difference >= 1.5: - return "超预期" - if difference <= -1.5: - return "低于预期" - return "符合预期" - - @staticmethod - def _auction_confirmation(row: dict[str, Any]) -> float: - volume_ratio = _number(row.get("volume_ratio")) - turnover_rate = _number(row.get("turnover_rate")) - amount_million = _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_rate >= 0.15 else -0.25 if turnover_rate < 0.03 else 0) - + (0.3 if amount_million >= 20 else 0.15 if amount_million >= 5 else -0.3 if amount_million < 1 else 0) - ) - - @staticmethod - def _attention_score( - row: dict[str, Any], - expected_change: float, - core_tags: list[str], - sources: list[str], - prior_streak: int, - strong_sector: bool, - ) -> float: - if core_tags: - identity_score = 35.0 - elif prior_streak >= 2: - identity_score = 27.0 - elif any(source in {"昨日涨停", "昨日炸板"} for source in sources): - identity_score = 21.0 - else: - identity_score = 14.0 - deviation_score = min(30.0, abs(_number(row.get("change")) - expected_change) * 5) - volume_score = min(10.0, max(0.0, _number(row.get("volume_ratio"))) / 2 * 10) - amount_score = min(6.0, max(0.0, _number(row.get("amount_million"))) / 10 * 6) - turnover_score = min(4.0, max(0.0, _number(row.get("turnover_rate"))) / 0.2 * 4) - theme_score = 15.0 if strong_sector else 7.0 if row.get("concepts") else 0.0 - return round(min(100.0, identity_score + deviation_score + volume_score + amount_score + turnover_score + theme_score), 1) - - def _auction_candidates( - self, - rows: list[dict[str, Any]], - baseline_date: str, - ) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]: - """Build a narrow, explainable universe from prior limits, breaks and top-20 hot lists.""" - snapshot = self.database.get_snapshot(baseline_date) or {} - prior_limits = list(snapshot.get("limits") or []) - prior_broken = list(snapshot.get("broken") or []) - prior_sectors = list(snapshot.get("sectors") or []) - strong_sector_names = { - str(item.get("name") or "") for item in prior_sectors[:5] if item.get("name") - } - ths_rows, dc_rows, errors = self._hot_rows(baseline_date) - candidates: dict[str, dict[str, Any]] = {} - core_tags: dict[str, set[str]] = {} - - def ensure_candidate(item: dict[str, Any]) -> dict[str, Any] | None: - code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0]) - if not code: - return None - return candidates.setdefault( - code, - { - "sources": [], - "streak": 0, - "sector": str(item.get("sector") or "其他"), - "name": str(item.get("name") or item.get("ts_name") or "--"), - "concepts": [], - "ths_rank": None, - "dc_rank": None, - }, - ) - - for item in prior_limits: - candidate = ensure_candidate(item) - if candidate is None: - continue - candidate["sources"].append("昨日涨停") - candidate["streak"] = max(1, int(_number(item.get("streak"), 1))) - - for item in prior_broken: - candidate = ensure_candidate(item) - if candidate is not None and "昨日炸板" not in candidate["sources"]: - candidate["sources"].append("昨日炸板") - - limit_streaks = [max(1, int(_number(item.get("streak"), 1))) for item in prior_limits] - highest_streak = max(limit_streaks, default=0) - for item in prior_limits: - code = str(item.get("code") or "") - streak = max(1, int(_number(item.get("streak"), 1))) - if streak >= 3: - core_tags.setdefault(code, set()).add("三板以上") - if highest_streak and streak == highest_streak: - core_tags.setdefault(code, set()).add("市场最高板") - - 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 not members: - continue - leader = max( - members, - key=lambda item: ( - int(_number(item.get("streak"), 1)), - _number(item.get("amount_billion")), - -_number(item.get("open_times")), - ), - ) - core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心") - - leadership = sorted( - prior_limits, - key=lambda item: ( - int(_number(item.get("streak"), 1)), - str(item.get("sector") or "") in strong_sector_names, - _number(item.get("amount_billion")), - ), - reverse=True, - ) - if leadership: - core_tags.setdefault(str(leadership[0].get("code") or ""), set()).add("市场领涨") - - hot_records: dict[str, dict[str, Any]] = {} - - for source, hot_rows, data_type in ( - ("同花顺热榜", ths_rows, "热股"), - ("东方财富热榜", dc_rows, "A股市场"), - ): - for item in hot_rows: - if str(item.get("data_type") or "") != data_type: - continue - ts_code = str(item.get("ts_code") or "") - code = ts_code.split(".")[0] - rank = max(1, int(_number(item.get("rank"), 9999))) - if not code or rank > 20: - continue - hot = hot_records.setdefault( - code, - { - "name": str(item.get("ts_name") or "--"), - "concepts": [], - "ths_rank": None, - "dc_rank": None, - }, - ) - hot["ths_rank" if source == "同花顺热榜" else "dc_rank"] = rank - if source == "同花顺热榜": - hot["concepts"] = self._parse_concepts(item.get("concept")) - - ranked_hot = sorted( - hot_records.items(), - key=lambda pair: ( - ((21 - (pair[1].get("ths_rank") or 21)) / 20) - + ((21 - (pair[1].get("dc_rank") or 21)) / 20) - + (0.35 if pair[1].get("ths_rank") and pair[1].get("dc_rank") else 0) - ), - reverse=True, - ) - for code, _ in ranked_hot[:5]: - core_tags.setdefault(code, set()).add("人气前5") - - for code, hot in hot_records.items(): - ranks = [rank for rank in (hot.get("ths_rank"), hot.get("dc_rank")) if isinstance(rank, int)] - dual = len(ranks) == 2 - if not ranks or (min(ranks) > 10 and not dual and code not in candidates and code not in core_tags): - continue - candidate = candidates.setdefault( - code, - { - "sources": [], - "streak": 0, - "sector": "其他", - "name": hot["name"], - "concepts": [], - "ths_rank": None, - "dc_rank": None, - }, - ) - candidate["ths_rank"] = hot.get("ths_rank") - candidate["dc_rank"] = hot.get("dc_rank") - candidate["concepts"] = hot.get("concepts") or [] - if hot.get("ths_rank") and "同花顺热榜" not in candidate["sources"]: - candidate["sources"].append("同花顺热榜") - if hot.get("dc_rank") and "东方财富热榜" not in candidate["sources"]: - candidate["sources"].append("东方财富热榜") - - normalized = [] - for row in rows: - candidate = candidates.get(str(row.get("code") or "")) - if not candidate: - continue - streak = int(candidate["streak"]) - expected_change = {1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0 if streak else 0.5) - ranks = [ - rank for rank in (candidate.get("ths_rank"), candidate.get("dc_rank")) - if isinstance(rank, int) - ] - if len(ranks) == 2: - expected_change += 0.8 - elif ranks: - best_rank = min(ranks) - expected_change += 0.7 if best_rank <= 10 else 0.4 if best_rank <= 30 else 0.2 - expected_change = min(expected_change, 6.5) - - volume_ratio = _number(row.get("volume_ratio")) - turnover_rate = _number(row.get("turnover_rate")) - amount_million = _number(row.get("amount_million")) - confirmation = self._auction_confirmation(row) - actual_strength = _number(row.get("change")) + confirmation - label = self._expectation_label(actual_strength, expected_change) - is_broken = "昨日炸板" in candidate["sources"] and "昨日涨停" not in candidate["sources"] - identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak == 1 else "昨日炸板" if is_broken else "人气榜标的" - popularity = ",双榜共识" if len(ranks) == 2 else ",热榜靠前" if ranks and min(ranks) <= 10 else "" - difference = _number(row.get("change")) - expected_change - direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合" - reason = ( - f"{identity}{popularity};竞价涨幅{direction}预期中枢" - f"{abs(difference):.1f}个百分点,量比{volume_ratio:.2f}" - ) - tags = sorted(core_tags.get(str(row.get("code") or ""), set())) - scored_row = { - **row, - "concepts": candidate["concepts"], - } - attention_score = self._attention_score( - scored_row, - expected_change, - tags, - candidate["sources"], - streak, - str(candidate.get("sector") or row.get("sector") or "") in strong_sector_names, - ) - normalized.append( - { - **scored_row, - "sector": candidate["sector"] if candidate["sector"] != "其他" else row.get("sector", "其他"), - "candidate_sources": candidate["sources"], - "source_label": " · ".join(candidate["sources"]), - "prior_streak": streak, - "concepts": candidate["concepts"], - "expected_change": round(expected_change, 2), - "actual_strength": round(actual_strength, 2), - "expectation": label, - "attention_score": attention_score, - "core_tags": tags, - "is_market_core": bool(tags), - "expectation_reason": reason, - } - ) - normalized.sort(key=lambda item: (_number(item.get("attention_score")), _number(item.get("amount_million"))), reverse=True) - matched_top = { - str(item.get("code") or "") - for item in sorted( - (item for item in normalized if item.get("expectation") == "符合预期"), - key=lambda item: _number(item.get("attention_score")), - reverse=True, - )[:20] - } - focus_candidates = [ - item for item in normalized - if item.get("is_market_core") - or (_number(item.get("attention_score")) >= 55 and item.get("expectation") != "符合预期") - or str(item.get("code") or "") in matched_top - ] - mandatory = [item for item in focus_candidates if item.get("is_market_core")] - mandatory_codes = {str(item.get("code") or "") for item in mandatory} - optional = [item for item in focus_candidates if str(item.get("code") or "") not in mandatory_codes] - focus_rows = sorted(mandatory, key=lambda item: _number(item.get("attention_score")), reverse=True) - focus_rows.extend(optional[:max(0, 30 - len(focus_rows))]) - focus_rows.sort(key=lambda item: _number(item.get("attention_score")), reverse=True) - return normalized, { - "baseline_date": _display_date(baseline_date), - "prior_limit_count": len(prior_limits), - "prior_broken_count": len(prior_broken), - "hot_candidate_count": sum( - any(source in {"同花顺热榜", "东方财富热榜"} for source in item["sources"]) - for item in candidates.values() - ), - "core_count": sum(bool(item.get("is_market_core")) for item in normalized), - "notice": ";".join(errors), - }, focus_rows - - @staticmethod - def _auction_theme_evidence( - prior_snapshot: dict[str, Any], - candidate_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 "其他") - matched = [row for row in candidate_rows if str(row.get("sector") or "其他") == name] - changes = [_number(row.get("change")) for row in matched] - middle = median(changes) if changes else -10.0 - positive_rate = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0.0 - if middle >= 2 and positive_rate >= 60: - status = "强承接" - elif middle >= 0 and positive_rate >= 50: - status = "有承接" - elif middle > -2: - status = "分歧" - else: - status = "承接弱" - carry.append( - { - "name": name, - "status": status, - "prior_limit_count": int(_number(sector.get("count"))), - "leader": str(sector.get("leader") or "--"), - "matched_count": len(matched), - "median_change": round(middle, 2) if matched else None, - "positive_rate": round(positive_rate, 1), - "amount_million": round(sum(_number(row.get("amount_million")) for row in matched), 2), - } - ) - - concept_groups: dict[str, list[dict[str, Any]]] = {} - prior_names = {str(item.get("name") or "") for item in prior_sectors} - for row in candidate_rows: - for concept in row.get("concepts") or []: - if concept and concept not in prior_names: - concept_groups.setdefault(str(concept), []).append(row) - new_themes = [] - for name, members in concept_groups.items(): - unique = {str(item.get("code") or ""): item for item in members} - values = list(unique.values()) - changes = [_number(item.get("change")) for item in values] - if len(values) < 2 or median(changes) < 2 or sum(value > 0.2 for value in changes) / len(values) < 0.67: - continue - new_themes.append( - { - "name": name, - "stock_count": len(values), - "median_change": round(median(changes), 2), - "amount_million": round(sum(_number(item.get("amount_million")) for item in values), 2), - "leaders": [str(item.get("name") or "--") for item in sorted(values, key=lambda value: _number(value.get("change")), reverse=True)[:3]], - } - ) - new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"], item["amount_million"]), reverse=True) - return {"carry": carry, "new_themes": new_themes[:8]} - - def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]: - dates = self.database.auction_factor_dates(trade_date, 10) - stock_list_dates = { - str(item.get("ts_code") or ""): str(item.get("list_date") or "") - for item in self.database.list_stock_master() - if item.get("ts_code") - } - history = [] - for current_date in dates: - rows = [ - row for row in self.database.auction_factors_for_date(current_date) - if ( - str(row.get("ts_code") or "") in stock_list_dates - and ( - not stock_list_dates[str(row.get("ts_code") or "")] - or stock_list_dates[str(row.get("ts_code") or "")] < current_date - ) - ) - ] - history.append( - { - "trade_date": _display_date(current_date), - "amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2), - "stock_count": len(rows), - } - ) - return history - - def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None: - existing = set(self.database.auction_factor_dates(trade_date, target_days + 5)) - if len(existing) >= target_days: - return - end = datetime.strptime(trade_date, "%Y%m%d") - start = (end - timedelta(days=35)).strftime("%Y%m%d") - try: - calendar = self.client.query( - "trade_cal", - { - "exchange": "SSE", - "start_date": start, - "end_date": trade_date, - "is_open": 1, - }, - "cal_date,is_open", - ) - except TushareError: - return - dates = sorted( - str(item.get("cal_date") or "") - for item in calendar - if int(_number(item.get("is_open"))) == 1 and item.get("cal_date") - )[-target_days:] - for current_date in dates: - if current_date in existing: - continue - try: - rows = self.client.query( - "stk_auction", - {"trade_date": current_date}, - "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share", - ) - except TushareError: - break - if rows: - self.database.upsert_auction_factors(rows) - existing.add(current_date) - - def _with_auction_watchlist( - self, - result: dict[str, Any], - trade_date: str, - user_id: int, - ) -> dict[str, Any]: - personalized = copy.deepcopy(result) - if not user_id: - personalized["watchlist_rows"] = [] - personalized["watchlist_missing_count"] = 0 - return personalized - watched = self.database.list_watchlist(user_id) - if not watched: - personalized["watchlist_rows"] = [] - personalized["watchlist_missing_count"] = 0 - return personalized - - public_rows = { - str(item.get("code") or ""): item - for item in ( - list(personalized.get("rows") or []) - + list(personalized.get("one_price_rows") or []) - ) - } - factors = { - str(item.get("ts_code") or "").split(".")[0]: item - for item in self.database.auction_factors_for_date(trade_date) - } - master = { - str(item.get("ts_code") or "").split(".")[0]: item - for item in self.database.list_stock_master() - } - rows = [] - missing = 0 - for item in watched: - code = str(item.get("code") or "") - if code in public_rows: - rows.append({**public_rows[code], "is_watchlist": True}) - continue - factor = factors.get(code) - if not factor: - missing += 1 - rows.append( - { - "code": code, - "name": str(item.get("name") or "--"), - "sector": str(item.get("sector") or "其他"), - "available": False, - "is_watchlist": True, - } - ) - continue - stock = master.get(code, {}) - price = _number(factor.get("price")) - pre_close = _number(factor.get("pre_close")) - change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0 - row = { - "code": code, - "ts_code": str(factor.get("ts_code") or ""), - "name": str(item.get("name") or stock.get("name") or "--"), - "sector": str(item.get("sector") or stock.get("industry") or "其他"), - "price": round(price, 2), - "pre_close": round(pre_close, 2), - "change": round(change, 2), - "amount_million": round(_number(factor.get("amount")) / 1_000_000, 2), - "turnover_rate": round(_number(factor.get("turnover_rate")), 4), - "volume_ratio": round(_number(factor.get("volume_ratio")), 2), - "candidate_sources": ["我的自选"], - "source_label": "我的自选", - "prior_streak": 0, - "concepts": [], - "expected_change": 0.0, - "core_tags": [], - "is_market_core": False, - "is_watchlist": True, - "available": True, - } - actual_strength = change + self._auction_confirmation(row) - row["actual_strength"] = round(actual_strength, 2) - row["expectation"] = self._expectation_label(actual_strength, 0.0) - row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False) - direction = "高于" if change > 0 else "低于" if change < 0 else "贴合" - row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}" - rows.append(row) - rows.sort( - key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))), - reverse=True, - ) - personalized["watchlist_rows"] = rows - personalized["watchlist_missing_count"] = missing - return personalized - - def _dynamic_auction_rows( - self, - trade_date: str, - baseline_date: str, - user_id: int, - ) -> list[dict[str, Any]]: - if not self.ifind or not self.ifind.configured: - return [] - master = self._stock_master() - placeholders = [ - { - "code": str(item.get("code") or ts_code.split(".")[0]), - "ts_code": ts_code, - "name": str(item.get("name") or "--"), - "sector": str(item.get("industry") or "其他"), - } - for ts_code, item in master.items() - ] - candidates, _, _ = self._auction_candidates(placeholders, baseline_date) - selected_codes = { - str(item.get("ts_code") or "") - for item in candidates - if item.get("ts_code") - } - if user_id: - watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)} - selected_codes.update( - ts_code for ts_code in master if ts_code.split(".")[0] in watched - ) - selected_codes.discard("") - if not selected_codes: - return [] - - display_date = _display_date(trade_date) - now = self._now_provider() - if now.tzinfo is None: - now = now.replace(tzinfo=CHINA_TIMEZONE) - else: - now = now.astimezone(CHINA_TIMEZONE) - end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25)) - end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}" - start_stamp = f"{display_date} 09:15:00" - snapshot_rows: list[dict[str, Any]] = [] - ordered_codes = sorted(selected_codes) - for index in range(0, len(ordered_codes), 80): - try: - snapshot_rows.extend( - self.ifind.snapshots( - ordered_codes[index:index + 80], - [ - "latest", "volume", "amount", "preClose", - "bid1", "bidSize1", "ask1", "askSize1", - ], - start_stamp, - end_stamp, - cache_ttl=8, - ) - ) - except IfindError: - continue - - latest: dict[str, dict[str, Any]] = {} - for row in snapshot_rows: - ts_code = str(row.get("thscode") or "") - previous = latest.get(ts_code) or {} - if ( - ts_code - and _number(row.get("latest")) > 0 - and str(row.get("time") or "") >= str(previous.get("time") or "") - ): - latest[ts_code] = row - prior_factors = { - str(item.get("ts_code") or ""): item - for item in self.database.auction_factors_for_date(baseline_date) - } - normalized = [] - for ts_code, row in latest.items(): - price = _number(row.get("latest")) - pre_close = _number(row.get("preClose")) - volume = _number(row.get("volume")) - bid_size = _number(row.get("bidSize1")) - ask_size = _number(row.get("askSize1")) - if volume <= 0 and bid_size > 0 and ask_size > 0: - volume = min(bid_size, ask_size) - amount = _number(row.get("amount")) - if amount <= 0 and price > 0 and volume > 0: - amount = price * volume - prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol")) - normalized.append( - { - "ts_code": ts_code, - "trade_date": trade_date, - "vol": volume, - "price": price, - "amount": amount, - "pre_close": pre_close, - "turnover_rate": 0, - "volume_ratio": volume / prior_volume if prior_volume > 0 else 0, - "float_share": 0, - "bid_size1": bid_size, - "ask_size1": ask_size, - "snapshot_time": str(row.get("time") or ""), - "dynamic": True, - } - ) - return normalized - - def auction_center( - self, - requested_date: str, - force: bool = False, - user_id: int = 0, - ) -> dict[str, Any]: - trade_date, previous_date = self._trade_context(requested_date) - session = self._auction_session(requested_date, trade_date) - phase = str(session["phase"]) - ifind_ready = bool(self.ifind and self.ifind.configured) - live_dynamic = phase == "observing" and ifind_ready - use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready - data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date - carried_forward = data_date != trade_date - cache_key = data_date - if not force and not live_dynamic: - cached = self.database.get_data_snapshot("auction_center_v6", cache_key) - if cached: - result = copy.deepcopy(cached) - result["meta"] = { - **result.get("meta", {}), - **session, - "requested_date": _display_date(requested_date), - "trade_date": _display_date(data_date), - "carried_forward": carried_forward, - "available": bool((result.get("summary") or {}).get("stock_count")), - "cached": True, - } - return self._with_auction_watchlist(result, data_date, user_id) - - if use_ifind_snapshot: - rows = self._dynamic_auction_rows(data_date, previous_date, user_id) - else: - rows = [] - if not rows and not live_dynamic: - try: - rows = self.client.query("stk_auction", {"trade_date": data_date}) - except TushareError: - rows = self.database.auction_factors_for_date(data_date) - if not rows: - return { - "meta": { - **session, - "requested_date": _display_date(requested_date), - "trade_date": _display_date(data_date), - "carried_forward": carried_forward, - "available": False, - "cached": False, - "notice": "该交易日暂无可用竞价快照", - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - }, - "summary": { - "stock_count": 0, "up_count": 0, "down_count": 0, - "limit_open_count": 0, "strong_open_count": 0, - "median_change": 0, "amount_billion": 0, - "candidate_count": 0, "focus_count": 0, "one_price_count": 0, - }, - "expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0}, - "candidate_meta": {"baseline_date": _display_date(previous_date)}, - "themes": {"carry": [], "new_themes": []}, - "amount_history": self._auction_amount_history(data_date), - "news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"}, - "focus_rows": [], "one_price_rows": [], "rows": [], - "watchlist_rows": [], "watchlist_missing_count": 0, - } - - master = self._stock_master() - try: - limit_rows = self.client.query( - "stk_limit", - {"trade_date": data_date}, - "trade_date,ts_code,up_limit,down_limit", - ) - except TushareError: - limit_rows = [] - limit_map = {str(item.get("ts_code") or ""): item for item in limit_rows} - normalized = [] - for row in rows: - ts_code = str(row.get("ts_code") or "") - stock = master.get(ts_code) - price = _number(row.get("price")) - pre_close = _number(row.get("pre_close")) - list_date = str((stock or {}).get("list_date") or "") - if ( - not stock - or price <= 0 - or pre_close <= 0 - or (list_date and list_date >= data_date) - ): - continue - change = (price / pre_close - 1) * 100 - amount_million = _number(row.get("amount")) / 1_000_000 - volume_ratio = _number(row.get("volume_ratio")) - turnover_rate = _number(row.get("turnover_rate")) - up_limit = _number((limit_map.get(ts_code) or {}).get("up_limit")) - is_one_price = bool( - up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005) - ) - normalized.append( - { - "code": str(stock.get("code") or ts_code.split(".")[0]), - "ts_code": ts_code, - "name": str(stock.get("name") or "--"), - "sector": str(stock.get("industry") or "其他"), - "price": round(price, 2), - "pre_close": round(pre_close, 2), - "change": round(change, 2), - "volume_ten_thousand": round(_number(row.get("vol")) / 10_000, 2), - "amount_million": round(amount_million, 2), - "turnover_rate": round(turnover_rate, 4), - "volume_ratio": round(volume_ratio, 2), - "up_limit": round(up_limit, 2) if up_limit else None, - "is_one_price": is_one_price, - "signal": ( - "竞价涨停" if change >= 9.5 else - "强势高开" if change >= 3 else - "高开" if change > 0.2 else - "深度低开" if change <= -3 else - "低开" if change < -0.2 else "平开" - ), - } - ) - normalized.sort(key=lambda item: (item["amount_million"], item["volume_ratio"]), reverse=True) - self.database.upsert_auction_factors(rows) - changes = [item["change"] for item in normalized] - total = len(normalized) - _, baseline_date = self._trade_context(data_date) - candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date) - candidate_map = {str(item.get("code") or ""): item for item in candidates} - one_price_rows = [] - for row in normalized: - if not row.get("is_one_price"): - continue - enriched = candidate_map.get(str(row.get("code") or ""), {}) - one_price_rows.append( - { - **row, - **enriched, - "attention_score": None, - "expectation": "", - "expected_change": None, - "expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离", - } - ) - one_price_codes = {str(item.get("code") or "") for item in one_price_rows} - candidates = [item for item in candidates if str(item.get("code") or "") not in one_price_codes] - focus_rows = [item for item in focus_rows if str(item.get("code") or "") 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, - ) - expectations = { - label: sum(item.get("expectation") == label for item in candidates) - for label in ("超预期", "符合预期", "低于预期") - } - prior_snapshot = self.database.get_snapshot(baseline_date) or {} - themes = self._auction_theme_evidence(prior_snapshot, candidates + one_price_rows) - self._ensure_auction_amount_history(data_date) - amount_history = self._auction_amount_history(data_date) - prior_amounts = [item["amount_billion"] for item in amount_history[:-1]] - current_amount = round(sum(item["amount_million"] for item in normalized) / 100, 2) - previous_amount = prior_amounts[-1] if prior_amounts else 0 - five_day_amounts = prior_amounts[-5:] - five_day_average = sum(five_day_amounts) / len(five_day_amounts) if five_day_amounts else 0 - result = { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(data_date), - "carried_forward": carried_forward, - "available": bool(normalized), - **session, - "cached": False, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - }, - "summary": { - "stock_count": total, - "up_count": sum(value > 0.2 for value in changes), - "down_count": sum(value < -0.2 for value in changes), - "limit_open_count": len(one_price_rows), - "strong_open_count": sum(value >= 3 for value in changes), - "median_change": round(median(changes), 2) if changes else 0, - "amount_billion": current_amount, - "amount_change_previous": round((current_amount / previous_amount - 1) * 100, 1) if previous_amount else None, - "amount_change_5d": round((current_amount / five_day_average - 1) * 100, 1) if five_day_average else None, - "candidate_count": len(candidates), - "focus_count": len(focus_rows), - "one_price_count": len(one_price_rows), - }, - "expectations": expectations, - "candidate_meta": candidate_meta, - "themes": themes, - "amount_history": amount_history, - "news_feedback": { - "available": False, - "message": "隔夜消息反馈暂不可用", - "detail": "待稳定的新闻与公告数据接入后开放", - }, - "focus_rows": focus_rows, - "one_price_rows": one_price_rows, - "rows": candidates, - } - if not live_dynamic: - self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result) - return self._with_auction_watchlist(result, data_date, user_id) - - def _theme_directory(self) -> list[dict[str, Any]]: - cached = self.database.get_data_snapshot("theme_directory_v1", "ths") or {} - if cached.get("items"): - return list(cached["items"]) - rows = self.client.query( - "ths_index", {}, "ts_code,name,count,exchange,list_date,type" - ) - items = [ - { - "code": str(row.get("ts_code") or ""), - "name": str(row.get("name") or ""), - "member_count": int(_number(row.get("count"))), - "list_date": str(row.get("list_date") or ""), - } - for row in rows - if str(row.get("type") or "").upper() == "N" - and str(row.get("exchange") or "").upper() == "A" - and row.get("ts_code") - and row.get("name") - ] - self.database.save_data_snapshot( - "theme_directory_v1", "ths", "market", {"items": items} - ) - return items - - def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]: - trade_date, previous_date = self._trade_context(requested_date) - if not force: - cached = self.database.get_data_snapshot("theme_library_v1", trade_date) - if cached: - result = copy.deepcopy(cached) - result["meta"] = {**result.get("meta", {}), "cached": True} - return result - - try: - daily = self.client.query( - "ths_daily", - {"trade_date": trade_date}, - "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate", - ) - except TushareError: - fallback = self._latest_feature_snapshot("theme_library_v1", trade_date) - if fallback: - result = copy.deepcopy(fallback) - result["meta"] = { - **result.get("meta", {}), - "requested_date": _display_date(requested_date), - "carried_forward": True, - "cached": True, - "notice": "当前题材行情暂不可用,展示最近有效快照", - } - return result - daily = [] - actual_date = trade_date - carried_forward = False - if not daily and previous_date: - try: - daily = self.client.query( - "ths_daily", - {"trade_date": previous_date}, - "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate", - ) - except TushareError: - daily = [] - actual_date = previous_date - carried_forward = bool(daily) - daily_map = {str(row.get("ts_code") or ""): row for row in daily} - try: - hot_rows = self.client.query("ths_hot", {"trade_date": actual_date}) - except TushareError: - hot_rows = [] - hot_map = { - str(row.get("ts_code") or ""): int(_number(row.get("rank"))) - for row in hot_rows - if str(row.get("data_type") or "") == "概念板块" - } - items = [] - for item in self._theme_directory(): - quote = daily_map.get(item["code"], {}) - items.append( - { - **item, - "change": round(_number(quote.get("pct_change")), 2), - "close": round(_number(quote.get("close")), 3), - "turnover_rate": round(_number(quote.get("turnover_rate")), 2), - "volume": round(_number(quote.get("vol")), 2), - "hot_rank": hot_map.get(item["code"]), - "has_quote": bool(quote), - } - ) - items.sort( - key=lambda item: ( - item["has_quote"], - item["hot_rank"] is not None, - -(item["hot_rank"] or 9999), - item["change"], - ), - reverse=True, - ) - quoted = [item for item in items if item["has_quote"]] - result = { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(actual_date), - "carried_forward": carried_forward, - "cached": False, - "notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录", - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - }, - "summary": { - "theme_count": len(items), - "quoted_count": len(quoted), - "up_count": sum(item["change"] > 0 for item in quoted), - "down_count": sum(item["change"] < 0 for item in quoted), - "hot_count": len(hot_map), - }, - "items": items, - } - self.database.save_data_snapshot("theme_library_v1", trade_date, "market", result) - return result - - def theme_detail(self, code: str, requested_date: str) -> dict[str, Any]: - code = str(code or "").strip().upper() - library = self.theme_library(requested_date) - theme = next((item for item in library["items"] if item["code"] == code), None) - if not theme: - raise ValueError("未找到对应题材。") - actual_date = str(library["meta"]["trade_date"]).replace("-", "") - detail_key = f"{actual_date}:{code}" - cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key) - if cached_detail: - return cached_detail - try: - members = self.client.query( - "ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name" - ) - except TushareError: - members = [] - bars = self.database.daily_bars_for_date(actual_date) - if not bars: - bars = self.client.query( - "daily", - {"trade_date": actual_date}, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - self.database.upsert_daily_bars(bars) - bar_map = {str(row.get("ts_code") or ""): row for row in bars} - normalized_members = [] - for member in members: - ts_code = str(member.get("con_code") or "") - quote = bar_map.get(ts_code, {}) - normalized_members.append( - { - "code": ts_code.split(".")[0], - "ts_code": ts_code, - "name": str(member.get("con_name") or "--"), - "price": round(_number(quote.get("close")), 2), - "change": round(_number(quote.get("pct_chg")), 2), - "amount_billion": round(_number(quote.get("amount")) / 100_000, 2), - "has_quote": bool(quote), - } - ) - normalized_members.sort( - key=lambda item: (item["has_quote"], item["change"], item["amount_billion"]), - reverse=True, - ) - end = datetime.strptime(actual_date, "%Y%m%d") - try: - history = self.client.query( - "ths_daily", - { - "ts_code": code, - "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"), - "end_date": actual_date, - }, - "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate", - ) - except TushareError: - history = [] - history.sort(key=lambda row: str(row.get("trade_date") or "")) - series = [ - { - "trade_date": _display_date(str(row.get("trade_date") or "")), - "open": _number(row.get("open")), - "high": _number(row.get("high")), - "low": _number(row.get("low")), - "close": _number(row.get("close")), - "change": _number(row.get("pct_change")), - "volume": _number(row.get("vol")), - } - for row in history[-90:] - ] - result = { - "meta": { - "trade_date": _display_date(actual_date), - "notice": "" if members or history else "题材成分与走势暂不可用", - }, - "theme": theme, - "series": series, - "members": normalized_members, - "summary": { - "member_count": len(normalized_members), - "up_count": sum(item["change"] > 0 for item in normalized_members if item["has_quote"]), - "down_count": sum(item["change"] < 0 for item in normalized_members if item["has_quote"]), - "quoted_count": sum(item["has_quote"] for item in normalized_members), - }, - } - if members or history: - self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result) - return result - - @staticmethod - def _parse_concepts(value: Any) -> list[str]: - if isinstance(value, list): - return [str(item) 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) for item in parsed if str(item).strip()] - except json.JSONDecodeError: - pass - return [part.strip() for part in text.split(",") if part.strip()] - - def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]: - trade_date, previous_date = self._trade_context(requested_date) - if not force: - cached = self.database.get_data_snapshot("popularity_v1", trade_date) - if cached: - result = copy.deepcopy(cached) - result["meta"] = {**result.get("meta", {}), "cached": True} - return result - - ths_rows, dc_rows, errors = self._hot_rows(trade_date) - actual_date = trade_date - carried_forward = False - if not ths_rows and not dc_rows and previous_date: - ths_rows, dc_rows, errors = self._hot_rows(previous_date) - actual_date = previous_date - carried_forward = bool(ths_rows or dc_rows) - if not ths_rows and not dc_rows: - fallback = self._latest_feature_snapshot("popularity_v1", trade_date) - if fallback: - result = copy.deepcopy(fallback) - result["meta"] = { - **result.get("meta", {}), - "requested_date": _display_date(requested_date), - "carried_forward": True, - "cached": True, - "notice": "当前榜单暂不可用,展示最近有效快照", - } - return result - return { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(trade_date), - "previous_trade_date": _display_date(previous_date), - "carried_forward": False, - "cached": False, - "notice": "该交易日暂无可用人气榜", - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - }, - "summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0}, - "combined": [], "ths": [], "dc": [], - } - - prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d") - prior_date, _ = self._trade_context(prior_request) - previous_ths, previous_dc, _ = self._hot_rows(prior_date) - ths = self._normalize_hot(ths_rows, "热股", previous_ths) - dc = self._normalize_hot(dc_rows, "A股市场", previous_dc) - ths_map = {item["ts_code"]: item for item in ths} - dc_map = {item["ts_code"]: item for item in dc} - combined = [] - for ts_code in set(ths_map) | set(dc_map): - ths_item = ths_map.get(ts_code) - dc_item = dc_map.get(ts_code) - 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": (ths_item or {}).get("concepts") or [], - } - ) - combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True) - for index, item in enumerate(combined, 1): - item["rank"] = index - result = { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(actual_date), - "previous_trade_date": _display_date(prior_date), - "carried_forward": carried_forward, - "cached": False, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": ";".join(errors), - }, - "summary": { - "ths_count": len(ths), - "dc_count": len(dc), - "dual_count": sum(item["dual_source"] for item in combined), - }, - "combined": combined[:200], - "ths": ths, - "dc": dc, - } - self.database.save_data_snapshot("popularity_v1", trade_date, "market", result) - return result - - def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]: - errors = [] - try: - ths = self.client.query("ths_hot", {"trade_date": trade_date}) - except TushareError: - ths = [] - errors.append("同花顺榜单暂不可用") - try: - dc = self.client.query("dc_hot", {"trade_date": trade_date}) - except TushareError: - dc = [] - errors.append("东方财富榜单暂不可用") - return ths, dc, errors - - def _normalize_hot( - self, - rows: list[dict[str, Any]], - data_type: str, - previous_rows: list[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 - rank = int(_number(row.get("rank"))) - ts_code = str(row.get("ts_code") or "") - prior_rank = previous.get(ts_code) - items.append( - { - "rank": rank, - "ts_code": ts_code, - "code": ts_code.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 - rank) if prior_rank else None, - "concepts": self._parse_concepts(row.get("concept")), - "reason": str(row.get("rank_reason") or ""), - "rank_time": str(row.get("rank_time") or ""), - } - ) - items.sort(key=lambda item: item["rank"]) - return items +from backend.features.market.insights import * diff --git a/app/tests/test_preservation_slice_market.py b/app/tests/test_preservation_slice_market.py index d15d042..a09b34e 100644 --- a/app/tests/test_preservation_slice_market.py +++ b/app/tests/test_preservation_slice_market.py @@ -20,6 +20,7 @@ ORIGINAL_ROOT = APP_ROOT.parent MARKET_METHODS = { "_tushare_client", + "_market_insights", "get_dashboard", "_dashboard_sentiment_ready", "_display_compact_date", diff --git a/app/tests/test_preservation_slice_market_insights.py b/app/tests/test_preservation_slice_market_insights.py new file mode 100644 index 0000000..0b15008 --- /dev/null +++ b/app/tests/test_preservation_slice_market_insights.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + +import market_insights +from backend.features.market import insights as canonical_insights + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +MARKET_INSIGHT_METHODS = { + "__init__", + "_trade_context", + "_latest_feature_snapshot", + "_auction_session", + "_stock_master", + "_expectation_label", + "_auction_confirmation", + "_attention_score", + "_auction_candidates", + "_auction_theme_evidence", + "_auction_amount_history", + "_ensure_auction_amount_history", + "_with_auction_watchlist", + "_dynamic_auction_rows", + "auction_center", + "_theme_directory", + "theme_library", + "theme_detail", + "_parse_concepts", + "popularity", + "_hot_rows", + "_normalize_hot", +} +MARKET_SERVICE_METHODS = {"_market_insights"} +AUCTION_SERVICE_METHODS = {"auction_center"} +THEME_SERVICE_METHODS = {"theme_library", "theme_detail"} +POPULARITY_SERVICE_METHODS = {"popularity"} +DRAGON_TIGER_SERVICE_METHODS = { + "get_hot_money_profiles", + "get_dragon_tiger", + "_apply_seat_aliases", +} +AUCTION_REPOSITORY_METHODS = { + "upsert_auction_factors", + "auction_factor_dates", + "auction_factors_for_date", +} +POPULARITY_REPOSITORY_METHODS = {"upsert_popularity_factors"} +DRAGON_TIGER_REPOSITORY_METHODS = { + "list_seat_aliases", + "save_seat_alias", + "upsert_lhb_institutions", +} +TUSHARE_METHODS = {"hot_money_profiles", "dragon_tiger"} + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase): + def assert_methods_equal( + self, + original_path: Path, + original_class: str, + migrated_path: Path, + migrated_class: str, + names: set[str], + ) -> None: + original = class_methods(original_path, original_class) + migrated = class_methods(migrated_path, migrated_class) + self.assertEqual(set(migrated), names) + for name in sorted(names): + self.assertEqual(migrated[name], original[name], name) + + def test_shared_market_insight_service_is_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "market_insights.py", + "MarketInsightsService", + APP_ROOT / "backend" / "features" / "market" / "insights.py", + "MarketInsightsService", + MARKET_INSIGHT_METHODS, + ) + self.assertIs(market_insights.MarketInsightsService, canonical_insights.MarketInsightsService) + + def test_dashboard_service_methods_are_exact_original_ast(self) -> None: + original = ORIGINAL_ROOT / "server.py" + mappings = ( + ("auction/service.py", "AuctionServiceMixin", AUCTION_SERVICE_METHODS), + ("themes/service.py", "ThemeServiceMixin", THEME_SERVICE_METHODS), + ("popularity/service.py", "PopularityServiceMixin", POPULARITY_SERVICE_METHODS), + ("dragon_tiger/service.py", "DragonTigerServiceMixin", DRAGON_TIGER_SERVICE_METHODS), + ) + for relative, class_name, names in mappings: + with self.subTest(relative=relative): + self.assert_methods_equal( + original, + "DashboardService", + APP_ROOT / "backend" / "features" / relative, + class_name, + names, + ) + original_methods = class_methods(original, "DashboardService") + market_methods = class_methods( + APP_ROOT / "backend" / "features" / "market" / "service.py", + "MarketServiceMixin", + ) + for name in MARKET_SERVICE_METHODS: + self.assertEqual(market_methods[name], original_methods[name], name) + + def test_repository_methods_are_exact_original_ast(self) -> None: + original = ORIGINAL_ROOT / "database.py" + mappings = ( + ("auction/repository.py", "AuctionRepositoryMixin", AUCTION_REPOSITORY_METHODS), + ("popularity/repository.py", "PopularityRepositoryMixin", POPULARITY_REPOSITORY_METHODS), + ("dragon_tiger/repository.py", "DragonTigerRepositoryMixin", DRAGON_TIGER_REPOSITORY_METHODS), + ) + for relative, class_name, names in mappings: + with self.subTest(relative=relative): + self.assert_methods_equal( + original, + "ReviewDatabase", + APP_ROOT / "backend" / "features" / relative, + class_name, + names, + ) + + def test_original_classes_no_longer_duplicate_moved_methods(self) -> None: + remaining_service = class_methods( + APP_ROOT / "backend" / "application.py", "DashboardService" + ) + remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase") + moved_service = ( + MARKET_SERVICE_METHODS + | AUCTION_SERVICE_METHODS + | THEME_SERVICE_METHODS + | POPULARITY_SERVICE_METHODS + | DRAGON_TIGER_SERVICE_METHODS + ) + moved_repository = ( + AUCTION_REPOSITORY_METHODS + | POPULARITY_REPOSITORY_METHODS + | DRAGON_TIGER_REPOSITORY_METHODS + ) + self.assertTrue(moved_service.isdisjoint(remaining_service)) + self.assertTrue(moved_repository.isdisjoint(remaining_database)) + + def test_tushare_dragon_tiger_implementations_are_exact_original_ast(self) -> None: + original = class_methods(ORIGINAL_ROOT / "tushare_client.py", "TushareClient") + migrated = class_methods( + APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py", + "TushareClient", + ) + for name in sorted(TUSHARE_METHODS): + self.assertEqual(migrated[name], original[name], name) + + def test_api_and_frontend_assets_are_unchanged(self) -> None: + for relative in ( + "config/api.config.json", + "static/index.html", + "static/app.js", + "static/styles.css", + "static/pages/auction/page.js", + "static/pages/themes/page.js", + "static/pages/popularity/page.js", + "static/pages/dragon-tiger/page.js", + ): + self.assertEqual( + sha256(APP_ROOT / relative), + sha256(ORIGINAL_ROOT / relative), + relative, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tools/compare_preservation_apis.py b/app/tools/compare_preservation_apis.py new file mode 100644 index 0000000..9ac6f53 --- /dev/null +++ b/app/tools/compare_preservation_apis.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import argparse +import hashlib +import http.cookiejar +import json +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +def request_json( + opener: urllib.request.OpenerDirector, + url: str, + payload: dict[str, Any] | None = None, +) -> tuple[int, Any]: + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers) + try: + with opener.open(request, timeout=90) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode("utf-8")) + + +def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector: + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()) + ) + status, body = request_json( + opener, + f"{base_url.rstrip('/')}/api/auth/login", + {"username": username, "password": password}, + ) + if status != 200 or not body.get("ok"): + raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}") + return opener + + +def digest(value: Any) -> str: + content = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(content).hexdigest() + + +def comparable(value: Any) -> Any: + if isinstance(value, dict): + return { + key: comparable(item) + for key, item in value.items() + if key != "request_id" + } + if isinstance(value, list): + return [comparable(item) for item in value] + return value + + +def main() -> None: + parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs") + parser.add_argument("--original", required=True) + parser.add_argument("--migrated", required=True) + parser.add_argument("--username", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("endpoints", nargs="+") + args = parser.parse_args() + + original = session(args.original, args.username, args.password) + migrated = session(args.migrated, args.username, args.password) + rows = [] + all_equal = True + for endpoint in args.endpoints: + original_status, original_body = request_json( + original, f"{args.original.rstrip('/')}{endpoint}" + ) + migrated_status, migrated_body = request_json( + migrated, f"{args.migrated.rstrip('/')}{endpoint}" + ) + original_comparable = comparable(original_body) + migrated_comparable = comparable(migrated_body) + equal = original_status == migrated_status and original_comparable == migrated_comparable + all_equal = all_equal and equal + rows.append( + { + "endpoint": endpoint, + "original_status": original_status, + "migrated_status": migrated_status, + "original_sha256": digest(original_comparable), + "migrated_sha256": digest(migrated_comparable), + "equal": equal, + } + ) + + result = {"all_equal": all_equal, "endpoints": rows} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if not all_equal: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/app/tools/compare_preservation_databases.py b/app/tools/compare_preservation_databases.py new file mode 100644 index 0000000..efb0d67 --- /dev/null +++ b/app/tools/compare_preservation_databases.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import sqlite3 +from pathlib import Path +from typing import Any + + +def digest(value: Any) -> str: + content = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str + ).encode("utf-8") + return hashlib.sha256(content).hexdigest() + + +def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]: + rows = connection.execute( + """ + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + """ + ).fetchall() + return [dict(row) for row in rows] + + +def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]: + quoted = '"' + table.replace('"', '""') + '"' + rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()] + return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Compare preservation SQLite databases") + parser.add_argument("--original", type=Path, required=True) + parser.add_argument("--migrated", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("tables", nargs="+") + args = parser.parse_args() + + original = sqlite3.connect(args.original) + migrated = sqlite3.connect(args.migrated) + original.row_factory = sqlite3.Row + migrated.row_factory = sqlite3.Row + try: + original_schema = schema(original) + migrated_schema = schema(migrated) + tables = [] + all_equal = original_schema == migrated_schema + for table in args.tables: + original_rows = table_rows(original, table) + migrated_rows = table_rows(migrated, table) + equal = original_rows == migrated_rows + all_equal = all_equal and equal + tables.append( + { + "table": table, + "original_count": len(original_rows), + "migrated_count": len(migrated_rows), + "original_sha256": digest(original_rows), + "migrated_sha256": digest(migrated_rows), + "equal": equal, + } + ) + result = { + "all_equal": all_equal, + "schema": { + "object_count": len(original_schema), + "original_sha256": digest(original_schema), + "migrated_sha256": digest(migrated_schema), + "equal": original_schema == migrated_schema, + }, + "tables": tables, + } + finally: + original.close() + migrated.close() + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if not all_equal: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/app/tools/run_preservation_runtime.py b/app/tools/run_preservation_runtime.py new file mode 100644 index 0000000..bd1cbf9 --- /dev/null +++ b/app/tools/run_preservation_runtime.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +import sys +from http.server import ThreadingHTTPServer +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run an isolated preservation runtime") + parser.add_argument("--runtime-root", type=Path, required=True) + parser.add_argument("--data-dir", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + + runtime_root = args.runtime_root.resolve() + data_dir = args.data_dir.resolve() + data_dir.mkdir(parents=True, exist_ok=True) + sys.path.insert(0, str(runtime_root)) + + if (runtime_root / "backend" / "bootstrap" / "config.py").is_file(): + from backend.bootstrap import config + + config.DATA_DIR = data_dir + config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills" + else: + import app_config as config + + config.DATA_DIR = data_dir + config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills" + + from server import RequestHandler, SERVICE + + server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler) + print(f"Preservation runtime is running at http://127.0.0.1:{args.port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + SERVICE._background_stop.set() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/docs/migration/evidence/slice-05/README.md b/docs/migration/evidence/slice-05/README.md new file mode 100644 index 0000000..40b135b --- /dev/null +++ b/docs/migration/evidence/slice-05/README.md @@ -0,0 +1,69 @@ +# 切片 05:集合竞价、题材库、人气热榜与龙虎榜 + +> 基线:`814e757`(切片 04) +> 回档标签:`xiaobai-preservation-slice-05-20260731` +> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片没有从`next/`取用代码,也没有重写计算、页面或接口。集合竞价、题材库和人气热榜原本 +共享`MarketInsightsService`,其中竞价候选会直接调用人气榜热度数据,因此整体移动到公共行情 +领域,避免拆出互相复制的实现;各页面入口仍按功能目录归位。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/market_insights.py` | `app/backend/features/market/insights.py` | 根级模块导出同一类对象 | +| `DashboardService`竞价入口 | `app/backend/features/auction/service.py` | `AuctionServiceMixin` | +| `DashboardService`题材入口 | `app/backend/features/themes/service.py` | `ThemeServiceMixin` | +| `DashboardService`人气入口 | `app/backend/features/popularity/service.py` | `PopularityServiceMixin` | +| `DashboardService`龙虎榜及游资档案 | `app/backend/features/dragon_tiger/service.py` | `DragonTigerServiceMixin` | +| 竞价、人气、龙虎榜持久化方法 | 对应功能目录的`repository.py` | `ReviewDatabase`继承原接口 | + +Tushare Provider 中`hot_money_profiles`与`dragon_tiger`继续保持切片02归位的唯一实现,没有为目录 +形式再制造一套数据构造逻辑。 + +## 2. 源码与接口等价 + +- `test_preservation_slice_market_insights.py`逐项比较22个市场洞察方法、8个页面服务方法、7个 + Repository方法和2个Tushare方法,全部与根目录原版无位置信息AST一致。 +- `DashboardService`与`ReviewDatabase`不再重复保留已移动方法;根级`market_insights`与新模块 + 暴露同一个`MarketInsightsService`类对象。 +- 原版`8784`和迁移版`8785`使用同一数据库的独立副本,集合竞价、题材库、题材详情、人气热榜、 + 龙虎榜、游资档案和席位别名共7个真实API状态码及JSON一致。 +- 题材详情在当前外部网络条件下两版均返回HTTP 400;差分只排除每次请求随机生成的 + `request_id`,错误码与错误内容仍完全一致。 +- 完整接口摘要见`api-diff.json`。 + +## 3. 数据库差分 + +- 两个副本均为62个schema对象,哈希均为 + `60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1`。 +- `auction_factors` 511914行、`popularity_factors` 232行、`lhb_institution_daily` 47行、 + `seat_aliases` 0行、`stock_master` 5535行均逐行一致。 +- 完整表计数与哈希见`database-diff.json`;运行数据库副本已在验收后删除,未提交凭据或正式数据。 + +## 4. 真实浏览器检查 + +- 1920×1080日间模式检查集合竞价、题材库、人气热榜和龙虎榜四页;均无横向溢出,控制台无 + 错误或警告。 +- 集合竞价载入30行重点候选;题材库载入394个题材及选中题材成分股;人气热榜载入3个摘要 + 模块和200行综合榜;龙虎榜按当前缓存显示既有不可用空态。 +- 四页HTML、主JS、CSS及各自页面JS与根目录原版字节哈希一致。 +- 截图SHA-256: + - `app-light-auction-1920x1080.png`:`8065086c8f2b360aeb1004bd60429f3e2d7f1b8c872ec4a965e9a5d0c016b915` + - `app-light-themes-1920x1080.png`:`80e1e41101497ee7213e1dadfaa4c9572a1c47b3495edd09e36745ccdb639402` + - `app-light-popularity-1920x1080.png`:`614d6b7770f4e9ec72c059ab8a4129486df9eff5c4dd7e8279cc68ebcb80a36b` + - `app-light-dragon-tiger-1920x1080.png`:`2f1e2ad3a9bc3874c73bae884fc8744177cef354c7176559da1453fab1f85993` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 258项通过 | +| `python -m unittest tests.test_preservation_slice_market_insights -q` | 6项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过 | +| `git diff --check` | 通过 | + +- 竞价、人气和龙虎榜因子同时服务切片06智能选股,迁移后仍由`ReviewDatabase`原方法名暴露。 +- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。 +- 没有删除待定代码、没有修改根目录正式数据库、没有切换Docker/NAS。 diff --git a/docs/migration/evidence/slice-05/api-diff.json b/docs/migration/evidence/slice-05/api-diff.json new file mode 100644 index 0000000..2f8827d --- /dev/null +++ b/docs/migration/evidence/slice-05/api-diff.json @@ -0,0 +1,61 @@ +{ + "all_equal": true, + "endpoints": [ + { + "endpoint": "/api/auction?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce", + "migrated_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce", + "equal": true + }, + { + "endpoint": "/api/themes?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1", + "migrated_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1", + "equal": true + }, + { + "endpoint": "/api/themes/detail?code=885001.TI&trade_date=2026-07-29", + "original_status": 400, + "migrated_status": 400, + "original_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a", + "migrated_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a", + "equal": true + }, + { + "endpoint": "/api/popularity?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94", + "migrated_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94", + "equal": true + }, + { + "endpoint": "/api/dragon-tiger?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843", + "migrated_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843", + "equal": true + }, + { + "endpoint": "/api/dragon-tiger/profiles", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "equal": true + }, + { + "endpoint": "/api/seat-aliases", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78", + "migrated_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png b/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png new file mode 100644 index 0000000..c98baa7 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png b/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png new file mode 100644 index 0000000..96ce5ff Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png b/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png new file mode 100644 index 0000000..c76e7a2 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png b/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png new file mode 100644 index 0000000..29c3b55 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/database-diff.json b/docs/migration/evidence/slice-05/database-diff.json new file mode 100644 index 0000000..f1aefd5 --- /dev/null +++ b/docs/migration/evidence/slice-05/database-diff.json @@ -0,0 +1,51 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true + }, + { + "table": "lhb_institution_daily", + "original_count": 47, + "migrated_count": 47, + "original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "equal": true + }, + { + "table": "seat_aliases", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "stock_master", + "original_count": 5535, + "migrated_count": 5535, + "original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "equal": true + } + ] +} diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index e8f0f1e..144e800 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T01:57:00+08:00", + "updated_at": "2026-07-31T02:42:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-05-auction-themes-popularity-dragon-tiger", - "last_completed_slice": "slice-04-ladder-rotation", - "last_checkpoint": "xiaobai-preservation-slice-04-20260731", - "next_action": "capture_slice-05_auction_theme_popularity_dragon_tiger_contracts_then_move_original_implementations", + "current_slice": "slice-06-screener-custom-tracking", + "last_completed_slice": "slice-05-auction-themes-popularity-dragon-tiger", + "last_checkpoint": "xiaobai-preservation-slice-05-20260731", + "next_action": "capture_slice-06_screener_custom_selection_and_tracking_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index 3128a10..862fedd 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片04“市场天梯与板块轮动”已完成 +> 当前状态:正式迁移,切片05“集合竞价、题材库、人气热榜与龙虎榜”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -24,6 +24,7 @@ | 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 | | 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 | | 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 | +| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 | ## 资产处置登记 @@ -43,6 +44,10 @@ | `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 | | `DashboardService`板块轮动方法 | 业务服务 | 板块轮动页 | 按职责机械移动 | `app/backend/features/rotation/service.py` | 2个方法AST、真实API与原版一致 | 已移动 | | Tushare天梯与轮动构造函数 | 公共数据计算 | 市场天梯、板块轮动 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个构造函数AST与原版一致 | 已归位 | +| `MarketInsightsService` | 共享市场洞察服务 | 集合竞价、题材库、人气热榜 | 整体机械移动,保留唯一共享实现 | `app/backend/features/market/insights.py` | 22个方法AST与原版一致;根级模块为同一类对象别名 | 已移动 | +| `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 | +| `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 | +| Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -100,6 +105,16 @@ - 回档:标签`xiaobai-preservation-slice-04-20260731`。 - 完整证据:`docs/migration/evidence/slice-04/README.md`。 +已完成切片:`slice-05-auction-themes-popularity-dragon-tiger`。 + +- 原版基线:提交`814e757`,即切片04回档点。 +- 迁移范围:共享市场洞察服务、竞价/题材/人气入口、龙虎榜与游资档案服务、7个相关持久化方法。 +- 兼容边界:根级`market_insights.py`保留同一类对象别名;竞价与人气共用候选热度逻辑,不复制第二套实现。 +- API与数据库:7个真实API逐字段一致,仅排除每次请求必然变化的`request_id`;62个schema对象与5张关键表完全一致。 +- 验收:258项Python测试、6项切片源码等价测试、45项Playwright测试及四个真实页面流程通过。 +- 回档:标签`xiaobai-preservation-slice-05-20260731`。 +- 完整证据:`docs/migration/evidence/slice-05/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |