from __future__ import annotations import re from datetime import datetime, timedelta from typing import Any from backend.bootstrap.config import ( normalize_date, tushare_code, validate_stock_code, validate_text, ) from backend.data.providers.tushare_client import TushareError class HeavenMarketContextMixin: def _resolve_heaven_stock_code(self, query: str) -> str: raw = validate_text(query, "股票代码或名称", 30, required=True) code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper()) if code_match: return validate_stock_code(code_match.group(1)) candidates = self.database.search_stock_master(raw) exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()] if not exact and self.configured: try: rows = self._tushare_client().query( "stock_basic", {"name": raw, "list_status": "L"}, "ts_code,symbol,name,industry,market,list_date", ) except TushareError: rows = [] if rows: self.database.upsert_stock_master(rows) candidates = self.database.search_stock_master(raw) exact = [ item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold() ] matches = exact or candidates if len(matches) == 1: return validate_stock_code(str(matches[0].get("code") or "")) if len(matches) > 1: choices = "、".join( f"{item.get('name') or '--'}({item.get('code') or '--'})" for item in matches[:5] ) raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。") raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。") def _heaven_stock_context( self, stock_code: str, trade_date: str, dashboard: dict[str, Any], market_mode: str, ) -> dict[str, Any]: """Return the only stock contract accepted by heaven trend.""" pool_row = next( ( dict(row) for key in ("limits", "broken", "down_limits") for row in dashboard.get(key) or [] if str(row.get("code") or "") == stock_code ), {}, ) if market_mode == "intraday": if self.configured: try: quote = self._tushare_client().realtime_stock_quote( tushare_code(stock_code), trade_date, ) return { **quote, "status": pool_row.get("status") or "普通", "seal_amount_million": pool_row.get("seal_amount_million") or 0, "open_times": pool_row.get("open_times") or 0, "streak": pool_row.get("streak") or 0, "precise": True, } except TushareError: pass if pool_row: return { **pool_row, "data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard", "trade_date": trade_date, "realtime": bool(dashboard.get("meta", {}).get("realtime")), "precise": False, } return { "code": stock_code, "name": "--", "sector": "其他", "trade_date": trade_date, "realtime": False, "precise": False, } detail = self.get_stock_detail(stock_code, trade_date, force=True) detail_meta = detail.get("meta") or {} stock = detail.get("stock") or {} resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date)) source = str(detail_meta.get("source") or "") return { "code": stock_code, "name": stock.get("name") or pool_row.get("name") or "--", "sector": stock.get("industry") or pool_row.get("sector") or "其他", "status": pool_row.get("status") or "普通", "change": stock.get("change") or 0, "turnover_rate": stock.get("turnover_rate") or 0, "amount_billion": stock.get("amount_billion") or 0, "seal_amount_million": pool_row.get("seal_amount_million") or 0, "open_times": pool_row.get("open_times") or 0, "streak": pool_row.get("streak") or 0, "data_source": source, "trade_date": resolved_date, "realtime": False, "precise": source == "tushare" and resolved_date == trade_date, } def _heaven_index_context( self, trade_date: str, dashboard: dict[str, Any], market_mode: str = "historical", ) -> dict[str, Any]: cached = self.database.get_data_snapshot("heaven_indices", trade_date) cached_valid = False if cached: cached_rows = list(cached.get("indices") or []) cached_dates = { str(row.get("trade_date") or "").replace("-", "") for row in cached_rows } cached_valid = ( len(cached_rows) == 3 and cached_dates == {trade_date} and bool(cached.get("precise")) and not cached.get("realtime") and str(cached.get("source") or "") == "tushare" and int(cached.get("schema_version") or 0) >= 3 ) if market_mode != "intraday" and cached_valid: return cached if not self.configured: error = "Tushare Token 未配置" else: try: client = self._tushare_client() if market_mode == "intraday": payload = self._aggregate_index_context(trade_date) payload["schema_version"] = 3 return payload payload = client.market_indices(trade_date) payload["schema_version"] = 3 if market_mode == "closed": payload["finalized"] = True self.database.save_data_snapshot( "heaven_indices", trade_date, str(payload.get("source") or "tushare"), payload, ) return payload except Exception as exc: error = str(exc) overview = dashboard.get("overview") or {} up_count = float(overview.get("up_count") or 0) down_count = float(overview.get("down_count") or 0) breadth = (up_count - down_count) / max(up_count + down_count, 1) return { "source": "market_breadth_proxy", "trade_date": trade_date, "realtime": False, "precise": False, "schema_version": 3, "notice": f"指数数据不可用,当前以市场宽度代理:{error}", "indices": [], "aggregate": { "average_pct_chg": round(breadth * 2.5, 3), "average_return_5d": 0, "average_return_20d": 0, }, } def _aggregate_index_context( self, trade_date: str, tushare_error: str = "", ) -> dict[str, Any]: quotes = self.realtime_aggregator.tencent_indices() epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes] quote_dates = { datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") for epoch in epochs if epoch } if len(quotes) != 3 or quote_dates != {trade_date}: raise ValueError("腾讯三大指数日期与目标交易日不一致") now = datetime.now().astimezone() max_skew = 120 if now.hour >= 15 else 15 if max(epochs) - min(epochs) > max_skew: raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒") code_map = { "000001": "000001.SH", "399001": "399001.SZ", "399006": "399006.SZ", } client = self._tushare_client() indices = [] start_date = ( datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20) ).strftime("%Y%m%d") for quote in quotes: ts_code = code_map[str(quote.get("code") or "")] history = client.query( "index_daily", {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, "ts_code,trade_date,close,pct_chg", ) history.sort(key=lambda item: str(item.get("trade_date") or "")) completed_closes = [ float(item.get("close") or 0) for item in history if str(item.get("trade_date") or "") < trade_date and float(item.get("close") or 0) > 0 ] close_5d = ( completed_closes[-5] if len(completed_closes) >= 5 else completed_closes[0] if completed_closes else 0 ) close = float(quote.get("price") or 0) indices.append( { "ts_code": ts_code, "name": quote.get("name") or ts_code, "trade_date": trade_date, "close": close, "pct_chg": round(float(quote.get("change") or 0), 3), "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, "return_20d": 0, "amount_billion": float(quote.get("amount_billion") or 0), "quote_time": quote.get("quote_time") or "", } ) return { "trade_date": trade_date, "source": "+".join( sorted({str(item.get("source") or "web_quote") for item in quotes}) + ["tushare_index_daily"] ), "realtime": True, "precise": True, "indices": indices, "aggregate": { "average_pct_chg": round( sum(item["pct_chg"] for item in indices) / len(indices), 3 ), "average_return_5d": round( sum(item["return_5d"] for item in indices) / len(indices), 3 ), "average_return_20d": 0, }, "quote_time_skew_seconds": max(epochs) - min(epochs), "notice": ( "指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。" + (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "") ), } def _heaven_sector_context( self, identifier: str, trade_date: str, market_mode: str = "historical", ) -> dict[str, Any] | None: """Return the Shenwan L2 sector context for heaven trend. 观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用 sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在 sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。 """ cache_key = f"{trade_date}:{identifier.strip().lower()}" cached = self.database.get_data_snapshot("heaven_sector", cache_key) cached_date = str((cached or {}).get("trade_date") or "").replace("-", "") cached_valid = bool( cached and cached_date == trade_date and cached.get("taxonomy") == "sw_l2" and cached.get("inner_precise", cached.get("precise")) and cached.get("outer_precise", cached.get("precise")) and not cached.get("realtime") and int(cached.get("schema_version") or 0) >= 6 ) if market_mode != "intraday" and cached_valid: return cached if not self.configured: return None try: payload = self._tushare_client().sw_sector_snapshot( tushare_code(identifier), trade_date, realtime_expected=market_mode == "intraday", allow_realtime_close=market_mode == "closed", ) except TushareError as exc: if cached_valid: return cached return { "name": "", "code": "", "taxonomy": "sw_l2", "source": "tushare", "trade_date": trade_date, "realtime": market_mode == "intraday", "precise": False, "inner_precise": False, "outer_precise": False, "coverage": 0, "member_count": 0, "quote_count": 0, "error": f"申万二级行业数据获取失败:{exc}", } if not payload.get("realtime") and payload.get("precise"): self.database.save_data_snapshot( "heaven_sector", cache_key, str(payload.get("source") or "tushare"), payload, ) return payload