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