from __future__ import annotations import copy import json import re from datetime import date, 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, _sector_coverage_issue from backend.features.heaven.agent import HeavenAgentError, interpret_heaven from backend.features.heaven.engine import ( _market_line_scores, _score_to_line, build_five_phase_field, build_market_hexagram, hexagram_from_lines, ) from backend.features.market import MarketServiceMixin class HeavenServiceMixin: @staticmethod def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]: intraday = market_mode == "intraday" fields = { "stock_amount_percentile": {"line": 1, "label": "成交额全市场分位", "unit": "%", "min": 0, "max": 100}, "stock_turnover_rate": {"line": 1, "label": "个股换手率", "unit": "%", "min": 0, "max": 100}, "stock_turnover_relative": {"line": 1, "label": "相对市场换手", "unit": "倍", "min": 0, "max": 20}, "stock_volume_activity_ratio": {"line": 1, "label": "同进度量能", "unit": "倍", "min": 0, "max": 20}, "stock_seal_amount_million": {"line": 1, "label": "封单金额", "unit": "万元", "min": 0, "max": 100000000}, "stock_open_times": {"line": 1, "label": "开板次数", "unit": "次", "min": 0, "max": 100, "integer": True}, "stock_change": {"line": 2, "label": "个股涨跌幅", "unit": "%", "min": -100, "max": 100}, "stock_streak": {"line": 2, "label": "连板高度", "unit": "板", "min": 0, "max": 100, "integer": True}, "stock_status": {"line": 2, "label": "个股状态", "type": "select", "options": ["普通", "涨停", "炸板", "跌停"]}, "sector_name": {"line": [3, 4], "label": "申万二级行业", "type": "text", "max_length": 50}, "sector_up_count": {"line": 3, "label": "行业上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "sector_down_count": {"line": 3, "label": "行业下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100}, "sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20}, "sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100}, "sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100}, "sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100}, "market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100}, "market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100}, "market_amount_billion": {"line": 5, "label": "两市成交额", "unit": "亿元", "min": 0, "max": 10000000}, "market_recent_average_amount_billion": {"line": 5, "label": "近期平均成交额", "unit": "亿元", "min": 0, "max": 10000000}, "market_up_count": {"line": 5, "label": "上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "market_down_count": {"line": 5, "label": "下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "market_limit_up_count": {"line": 5, "label": "涨停家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "market_limit_down_count": {"line": 5, "label": "跌停家数", "unit": "家", "min": 0, "max": 10000, "integer": True}, "index_sh_change": {"line": 6, "label": "上证指数涨跌幅", "unit": "%", "min": -20, "max": 20}, "index_sz_change": {"line": 6, "label": "深证成指涨跌幅", "unit": "%", "min": -20, "max": 20}, "index_cy_change": {"line": 6, "label": "创业板指涨跌幅", "unit": "%", "min": -20, "max": 20}, "note": {"line": [], "label": "补录说明", "type": "text", "max_length": 200}, } if intraday: for key in ("stock_seal_amount_million", "stock_open_times"): fields.pop(key) else: for key in ("stock_turnover_relative", "stock_volume_activity_ratio", "sector_relative_turnover"): fields.pop(key) return fields @classmethod def _validate_heaven_manual_data( cls, raw: Any, market_mode: str ) -> dict[str, Any]: if raw in (None, ""): return {} if not isinstance(raw, dict): raise ValueError("六爻补录数据格式不正确。") schema = cls._heaven_manual_schema(market_mode) unknown = set(raw) - set(schema) if unknown: raise ValueError(f"六爻补录包含未知字段:{next(iter(sorted(unknown)))}") values: dict[str, Any] = {} for key, value in raw.items(): if value is None or (isinstance(value, str) and not value.strip()): continue spec = schema[key] if spec.get("type") == "text": values[key] = validate_text(value, spec["label"], int(spec["max_length"])) continue if spec.get("type") == "select": text = str(value).strip() if text not in spec["options"]: raise ValueError(f"{spec['label']}不在允许范围内。") values[key] = text continue try: number = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"{spec['label']}必须是数字。") from exc if number < float(spec["min"]) or number > float(spec["max"]): raise ValueError( f"{spec['label']}应在 {spec['min']} 至 {spec['max']} 之间。" ) values[key] = int(number) if spec.get("integer") else number return values @staticmethod def _apply_heaven_manual_data( dashboard: dict[str, Any], index_context: dict[str, Any], sector: dict[str, Any] | None, stock: dict[str, Any] | None, manual_data: dict[str, Any], market_mode: str, trade_date: str, stock_code: str, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: dashboard = copy.deepcopy(dashboard) index_context = copy.deepcopy(index_context or {}) sector = copy.deepcopy(sector or {}) stock = copy.deepcopy(stock or {}) overview = dashboard.setdefault("overview", {}) stock_map = { "stock_amount_percentile": "amount_percentile", "stock_turnover_rate": "turnover_rate", "stock_turnover_relative": "turnover_relative", "stock_volume_activity_ratio": "volume_activity_ratio", "stock_seal_amount_million": "seal_amount_million", "stock_open_times": "open_times", "stock_change": "change", "stock_streak": "streak", "stock_status": "status", } sector_map = { "sector_name": "name", "sector_up_count": "up_count", "sector_down_count": "down_count", "sector_coverage": "coverage", "sector_relative_turnover": "relative_turnover", "sector_member_equal_change": "member_equal_change", "sector_change": "change", "sector_leading_pct": "leading_pct", } overview_map = { "market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate", "market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion", "market_up_count": "up_count", "market_down_count": "down_count", "market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count", } for manual_key, target in stock_map.items(): if manual_key in manual_data: stock[target] = manual_data[manual_key] for manual_key, target in sector_map.items(): if manual_key in manual_data: sector[target] = manual_data[manual_key] for manual_key, target in overview_map.items(): if manual_key in manual_data: overview[target] = manual_data[manual_key] if any(key.startswith("stock_") for key in manual_data): stock.setdefault("code", stock_code) stock.setdefault("name", stock_code or "--") stock["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical" if market_mode == "intraday" and "stock_volume_activity_ratio" in manual_data: stock["activity_source"] = "user_supplied" if any(key.startswith("sector_") for key in manual_data): sector["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical" sector.setdefault("taxonomy", "sw_l2") index_keys = ( ("index_sh_change", "000001.SH", "上证指数"), ("index_sz_change", "399001.SZ", "深证成指"), ("index_cy_change", "399006.SZ", "创业板指"), ) rows = {str(row.get("ts_code") or row.get("code") or ""): dict(row) for row in index_context.get("indices") or []} for manual_key, code, name in index_keys: if manual_key not in manual_data: continue row = rows.get(code, {"ts_code": code, "name": name}) row.update({"pct_chg": manual_data[manual_key], "trade_date": trade_date}) rows[code] = row ordered_rows = [rows.get(code) for _, code, _ in index_keys] if all(ordered_rows): index_context["indices"] = ordered_rows changes = [float(row.get("pct_chg") or 0) for row in ordered_rows] aggregate = dict(index_context.get("aggregate") or {}) aggregate["average_pct_chg"] = sum(changes) / 3 index_context["aggregate"] = aggregate return dashboard, index_context, sector, stock @classmethod def _heaven_line_checks( cls, trade_date: str, dashboard: dict[str, Any], recent_history: list[dict[str, Any]], index_context: dict[str, Any], sector: dict[str, Any], stock: dict[str, Any], market_mode: str, manual_data: dict[str, Any], ) -> list[dict[str, Any]]: intraday = market_mode == "intraday" closed = market_mode == "closed" schema = cls._heaven_manual_schema(market_mode) required = { 1: (["stock_amount_percentile", "stock_turnover_relative", "stock_volume_activity_ratio"] if intraday else ["stock_amount_percentile", "stock_turnover_rate", "stock_seal_amount_million", "stock_open_times"]), 2: ["stock_change", "stock_streak", "stock_status"], 3: (["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_relative_turnover"] if intraday else ["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_member_equal_change", "sector_leading_pct"]), 4: ["sector_name", "sector_change", "sector_leading_pct"], 5: ["market_sentiment_score", "market_seal_rate", "market_amount_billion", "market_recent_average_amount_billion", "market_up_count", "market_down_count", "market_limit_up_count", "market_limit_down_count"], 6: ["index_sh_change", "index_sz_change", "index_cy_change"], } names = { 1: ("初爻", "个股内核", "成交活跃、换手与量能"), 2: ("二爻", "个股外显", "涨跌、连板与状态"), 3: ("三爻", "行业内核", "行业宽度与成交活跃"), 4: ("四爻", "行业外显", "行业涨跌与领涨表现"), 5: ("五爻", "市场内核", "情绪、封板、成交与市场宽度"), 6: ("上爻", "指数外显", "三大指数当日涨跌"), } index_date = str(index_context.get("trade_date") or "").replace("-", "") index_rows = list(index_context.get("indices") or []) index_dates = {str(row.get("trade_date") or "").replace("-", "") for row in index_rows} index_issues = [] if len(index_rows) < 3: index_issues.append(f"三大指数仅取得 {len(index_rows)}/3 条行情") elif index_date != trade_date or index_dates != {trade_date}: actual_dates = "、".join(sorted(value for value in index_dates if value)) or "未知" index_issues.append(f"指数实际日期为 {actual_dates},目标交易日为 {trade_date}") elif not index_context.get("precise"): index_issues.append("三大指数行情未通过完整性校验") elif intraday and not index_context.get("realtime"): index_issues.append("盘中缺少可核验的实时指数行情") elif not intraday and (index_context.get("realtime") or str(index_context.get("source") or "") != "tushare"): index_issues.append("收盘或历史行情不是官方指数日线") sector_date = str(sector.get("trade_date") or "").replace("-", "") sector_coverage = float(sector.get("coverage") or 0) sector_explained_count = int( sector.get("explained_count") if sector.get("explained_count") is not None else sector.get("quote_count") or 0 ) sector_explained_coverage = float( sector.get("explained_coverage") if sector.get("explained_coverage") is not None else sector_coverage ) sector_coverage_issue = _sector_coverage_issue( int(sector.get("member_count") or 0), int(sector.get("quote_count") or 0), sector_explained_coverage, sector_explained_count, ) sector_common = [] if not sector: sector_common.append("未取得申万二级行业归属") elif sector.get("taxonomy") != "sw_l2": sector_common.append("行业分类不是申万二级") elif sector_date != trade_date: sector_common.append("行业行情日期与目标交易日不一致") elif intraday and not sector.get("realtime"): sector_common.append("盘中行业行情不是申万实时行情") elif market_mode == "historical" and sector.get("realtime"): sector_common.append("历史行业行情不能使用实时快照") elif closed and sector.get("realtime") and not sector.get("finalized"): sector_common.append("收盘行业实时行情尚未形成15:00最终快照") sector_inner = list(sector_common) sector_outer = list(sector_common) if not sector.get("inner_precise", sector.get("precise")): sector_inner.append(str(sector.get("inner_error") or sector.get("error") or "行业内核数据未通过校验")) if not sector.get("outer_precise", sector.get("precise")): sector_outer.append(str(sector.get("outer_error") or sector.get("error") or "行业外显数据未通过校验")) if sector and sector_coverage_issue and sector_coverage_issue not in sector_inner: sector_inner.append(sector_coverage_issue) if sector.get("realtime") and not sector.get("relative_turnover"): sector_inner.append("缺少行业相对全市场换手活跃度") stock_date = str(stock.get("trade_date") or "").replace("-", "") stock_common = [] if not stock.get("code"): stock_common.append("尚未载入有效个股") elif stock_date != trade_date: stock_common.append(f"个股实际日期为 {stock_date or '未知'},目标交易日为 {trade_date}") elif not stock.get("precise"): stock_common.append("个股行情未通过完整性校验") elif intraday and not stock.get("realtime"): stock_common.append("盘中个股行情不是实时行情") elif not intraday and (stock.get("realtime") or str(stock.get("data_source") or "") != "tushare"): stock_common.append("收盘或历史个股行情不是官方日线") stock_inner = list(stock_common) if intraday and stock.get("turnover_source") in {None, "", "unavailable"}: stock_inner.append("缺少可核验的实时换手率") if intraday and stock.get("activity_source") in {None, "", "unavailable"}: stock_inner.append("缺少同时间进度量能基准") overview = dashboard.get("overview") or {} market_key_map = { "market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate", "market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion", "market_up_count": "up_count", "market_down_count": "down_count", "market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count", } market_issues = [] for manual_key, source_key in market_key_map.items(): if source_key == "recent_average_amount_billion": history_values = [item.get("amount_billion") for item in recent_history[:-1] if item.get("amount_billion") is not None] if source_key not in overview and not history_values: market_issues.append(f"缺少{schema[manual_key]['label']}") elif source_key not in overview or overview.get(source_key) is None: market_issues.append(f"缺少{schema[manual_key]['label']}") automatic_issues = { 1: stock_inner, 2: stock_common, 3: sector_inner, 4: sector_outer, 5: market_issues, 6: index_issues, } limits = list(dashboard.get("limits") or []) scores = _market_line_scores(dashboard, recent_history, index_context, sector, stock, limits) value_map: dict[str, Any] = { "stock_amount_percentile": stock.get("amount_percentile"), "stock_turnover_rate": stock.get("turnover_rate"), "stock_turnover_relative": stock.get("turnover_relative"), "stock_volume_activity_ratio": stock.get("volume_activity_ratio"), "stock_seal_amount_million": stock.get("seal_amount_million"), "stock_open_times": stock.get("open_times"), "stock_change": stock.get("change"), "stock_streak": stock.get("streak"), "stock_status": stock.get("status"), "sector_name": sector.get("name"), "sector_up_count": sector.get("up_count"), "sector_down_count": sector.get("down_count"), "sector_coverage": sector.get("coverage"), "sector_relative_turnover": sector.get("relative_turnover"), "sector_member_equal_change": sector.get("member_equal_change"), "sector_change": sector.get("change"), "sector_leading_pct": sector.get("leading_pct"), "market_sentiment_score": overview.get("sentiment_score"), "market_seal_rate": overview.get("seal_rate"), "market_amount_billion": overview.get("amount_billion"), "market_recent_average_amount_billion": overview.get("recent_average_amount_billion"), "market_up_count": overview.get("up_count"), "market_down_count": overview.get("down_count"), "market_limit_up_count": overview.get("limit_up_count"), "market_limit_down_count": overview.get("limit_down_count"), } history_values = [float(item.get("amount_billion")) for item in recent_history[:-1] if item.get("amount_billion") is not None] if value_map["market_recent_average_amount_billion"] is None and history_values: value_map["market_recent_average_amount_billion"] = sum(history_values) / len(history_values) if value_map["stock_amount_percentile"] is None and not intraday: amount = float(stock.get("amount_billion") or 0) amounts = [float(item.get("amount_billion") or 0) for item in limits if item.get("amount_billion") is not None] value_map["stock_amount_percentile"] = ( sum(item <= amount for item in amounts) / len(amounts) * 100 if amounts else None ) row_by_code = {str(row.get("ts_code") or row.get("code") or ""): row for row in index_context.get("indices") or []} value_map.update({ "index_sh_change": (row_by_code.get("000001.SH") or {}).get("pct_chg"), "index_sz_change": (row_by_code.get("399001.SZ") or {}).get("pct_chg"), "index_cy_change": (row_by_code.get("399006.SZ") or {}).get("pct_chg"), }) def missing_value(key: str) -> bool: value = value_map.get(key) return value is None or (isinstance(value, str) and not value.strip()) invalid_fields = { line_number: {key for key in keys if missing_value(key)} for line_number, keys in required.items() } if stock_common: invalid_fields[1].update(required[1]) invalid_fields[2].update(required[2]) else: if intraday and stock.get("turnover_source") in {None, "", "unavailable"}: invalid_fields[1].add("stock_turnover_relative") if intraday and stock.get("activity_source") in {None, "", "unavailable"}: invalid_fields[1].add("stock_volume_activity_ratio") if sector_common: invalid_fields[3].update(required[3]) invalid_fields[4].update(required[4]) else: if not sector.get("inner_precise", sector.get("precise")) or sector_coverage_issue: invalid_fields[3].update(key for key in required[3] if key != "sector_name") if sector.get("realtime") and not sector.get("relative_turnover"): invalid_fields[3].add("sector_relative_turnover") # The official SW index supplies only the sector's external change. A valid # membership name and member-stock leader remain usable when that quote fails. if not sector.get("outer_precise", sector.get("precise")): invalid_fields[4].add("sector_change") if index_issues: invalid_fields[6].update(required[6]) checks = [] for line_number in range(1, 7): manual_keys = [key for key in required[line_number] if key in manual_data] unresolved_fields = [ key for key in required[line_number] if key in invalid_fields[line_number] and key not in manual_data ] hard_missing_identity = line_number in {1, 2} and not stock.get("code") passed = not hard_missing_identity and not unresolved_fields status = "manual" if passed and manual_keys else "passed" if passed else "failed" reasons = [] if passed else [ *( ["请先输入并载入股票代码或名称"] if hard_missing_identity else automatic_issues[line_number] ), *( ["需补充:" + "、".join(schema[key]["label"] for key in unresolved_fields)] if unresolved_fields else [] ), ] score = float(scores[line_number - 1]["score"]) position, layer, formula = names[line_number] checks.append({ "line": line_number, "position": position, "layer": layer, "formula": formula, "status": status, "passed": passed, "reasons": reasons, "score": round(score, 3) if passed else None, "line_value": _score_to_line(score) if passed else None, "evidence": scores[line_number - 1]["evidence"] if passed else [], "fields": [ { "key": key, "label": schema[key]["label"], "unit": schema[key].get("unit", ""), "type": schema[key].get("type", "number"), "options": schema[key].get("options", []), "value": value_map.get(key), "manual": key in manual_data, "required": True, "min": schema[key].get("min"), "max": schema[key].get("max"), "integer": bool(schema[key].get("integer")), } for key in required[line_number] ], }) return checks 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_setup( self, trade_date: str, sector_name: str = "", stock_code: str = "", manual_data: dict[str, Any] | None = None, ) -> dict[str, Any]: normalized_date = normalize_date(trade_date) dashboard = self.get_dashboard(normalized_date) data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date)) recent_history = self.database.snapshot_summaries(data_date, 10) market_mode = self._heaven_market_mode(data_date, dashboard) manual_data = self._validate_heaven_manual_data(manual_data, market_mode) index_context = self._heaven_index_context(data_date, dashboard, market_mode) external_stock = None normalized_stock_code = "" if stock_code.strip(): normalized_stock_code = self._resolve_heaven_stock_code(stock_code) external_stock = self._heaven_stock_context( normalized_stock_code, data_date, dashboard, market_mode, ) external_sector = None if normalized_stock_code and self.configured: external_sector = self._heaven_sector_context( normalized_stock_code, data_date, market_mode, ) if external_sector and external_stock: external_stock["sector"] = external_sector.get("name") or external_stock.get("sector") dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data( dashboard, index_context, external_sector, external_stock, manual_data, market_mode, data_date, normalized_stock_code, ) if external_sector and external_stock: external_stock["sector"] = external_sector.get("name") or external_stock.get("sector") sector_input = str((external_sector or {}).get("name") or sector_name.strip()) if not normalized_stock_code: data_checks = [] chart = { "available": False, "selection_required": True, "data_trade_date": data_date, "sector": "", "sector_code": "", "sector_taxonomy": "", "stock": {"code": "", "name": "", "status": ""}, "quality": { "status": "awaiting_selection", "issues": [], "principle": "", "sources": [], }, "index_context": index_context, } else: data_checks = self._heaven_line_checks( data_date, dashboard, recent_history, index_context, external_sector or {}, external_stock or {}, market_mode, manual_data, ) quality_issues = [ f"{check['position']}·{check['layer']}:{';'.join(check['reasons'])}" for check in data_checks if not check["passed"] ] if quality_issues: chart = { "available": False, "selection_required": False, "data_trade_date": data_date, "sector": str((external_sector or {}).get("name") or sector_input or "--"), "sector_code": str((external_sector or {}).get("code") or ""), "sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""), "stock": { "code": normalized_stock_code, "name": str((external_stock or {}).get("name") or "--"), "status": str((external_stock or {}).get("status") or ""), }, "quality": { "status": "blocked", "issues": quality_issues, "principle": "六爻任一层缺少同日、同口径的有效数据,本系统不成卦。", "sources": self._heaven_trend_sources( data_date, index_context, external_sector, external_stock ), }, "index_context": index_context, } else: chart = build_market_hexagram( dashboard, recent_history, index_context, sector_input, normalized_stock_code, external_stock, external_sector, ) chart["available"] = True chart["selection_required"] = False manual_active = any(check["status"] == "manual" for check in data_checks) chart["quality"] = { "status": "manual" if manual_active else "verified", "issues": [], "principle": ( "自动行情与用户补充数据均已通过同一套量化公式校验。" if manual_active else "指数、板块、个股均已通过同日同口径校验。" ), "sources": [ *self._heaven_trend_sources( data_date, index_context, external_sector, external_stock ), *([{ "lines": "补录爻位", "layer": "用户补充", "realtime": market_mode == "intraday", "detail": str(manual_data.get("note") or "量化数据经原公式重新计算"), }] if manual_active else []), ], } chart["data_checks"] = data_checks chart["manual_data"] = manual_data sector_phase_overrides = self.database.list_sector_phase_overrides() field = build_five_phase_field( normalized_date, sector_phase_overrides, ) personal_profile = self.account_personal_field( normalized_date, field, public=True, ) daily_fortune_reading = self.database.latest_heaven_reading( self.current_user_id, "fortune", normalized_date ) if self._legacy_truncated_heaven_reading(daily_fortune_reading): daily_fortune_reading = None return { "trade_date": data_date, "calendar_date": normalized_date, "market_mode": market_mode, "chart": chart, "field": field, "personal_profile": personal_profile, "daily_fortune_reading": daily_fortune_reading, "sector_phase_overrides": [ {"name": name, "element": element} for name, element in sector_phase_overrides.items() ], "llm": { "configured": self.llm_configured, "model": self.llm_primary_model if self.llm_configured else "", "fallback_configured": self.llm_fallback_configured, "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", }, } 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, } @staticmethod def _heaven_market_mode( trade_date: str, dashboard: dict[str, Any], now: datetime | None = None, ) -> str: """区分盘中、今日收盘和历史,避免把 rt_k 数据来源误当成交易状态。""" now = now or datetime.now().astimezone() if trade_date != now.strftime("%Y%m%d"): return "historical" meta = dashboard.get("meta") or {} status = str(meta.get("market_status") or "").lower() local_time = now.time().replace(tzinfo=None) if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time(): return "closed" if status in {"trading", "auction", "pre_open"} or ( bool(meta.get("realtime")) and local_time >= datetime.strptime("09:15", "%H:%M").time() ): return "intraday" return "historical" @staticmethod def _heaven_trend_sources( trade_date: str, index_context: dict[str, Any], sector: dict[str, Any] | None, stock: dict[str, Any] | None, ) -> list[dict[str, Any]]: sector = sector or {} stock = stock or {} return [ { "lines": "五爻、上爻", "layer": "指数", "source": index_context.get("source") or "unavailable", "trade_date": index_context.get("trade_date") or "", "realtime": bool(index_context.get("realtime")), "detail": f"三大指数 {len(index_context.get('indices') or [])}/3", }, { "lines": "三爻、四爻", "layer": "行业", "source": sector.get("source") or "unavailable", "trade_date": sector.get("trade_date") or "", "realtime": bool(sector.get("realtime")), "detail": ( f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} " f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}" ), }, { "lines": "初爻、二爻", "layer": "个股", "source": stock.get("data_source") or "unavailable", "trade_date": stock.get("trade_date") or trade_date, "realtime": bool(stock.get("realtime")), "detail": ( f"{stock.get('name') or '--'};换手基准 " f"{stock.get('capital_trade_date') or '--'}" ), }, ] @staticmethod def _heaven_trend_quality_issues( trade_date: str, dashboard: dict[str, Any], index_context: dict[str, Any], sector: dict[str, Any] | None, stock: dict[str, Any] | None, market_mode: str = "historical", ) -> list[str]: issues: list[str] = [] intraday = market_mode == "intraday" closed = market_mode == "closed" if intraday: meta = dashboard.get("meta") or {} market_status = str(meta.get("market_status") or "") now = datetime.now().astimezone() try: updated_at = datetime.fromisoformat(str(meta.get("updated_at") or "")) if updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=now.tzinfo) snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds() except ValueError: snapshot_age = float("inf") if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120: issues.append("主行情快照超过2分钟,请点击顶部刷新") # 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将 # rt_k 快照替换成同日盘后日线而不带该字段;六爻数据本身的日期、 # 完整性和来源校验已足以判断是否可以成卦。 index_date = str(index_context.get("trade_date") or "").replace("-", "") index_rows = list(index_context.get("indices") or []) index_row_dates = { str(row.get("trade_date") or "").replace("-", "") for row in index_rows } if not index_context.get("precise") or len(index_rows) < 3: issues.append("指数层缺少三大指数的有效行情") elif index_date != trade_date or index_row_dates != {trade_date}: issues.append("指数行情与目标交易日不一致") elif intraday and not index_context.get("realtime"): issues.append("盘中指数层缺少可核验的实时行情") elif not intraday and ( index_context.get("realtime") or str(index_context.get("source") or "") != "tushare" ): issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线") sector = sector or {} sector_date = str(sector.get("trade_date") or "").replace("-", "") sector_coverage = float(sector.get("coverage") or 0) sector_explained_count = int( sector.get("explained_count") if sector.get("explained_count") is not None else sector.get("quote_count") or 0 ) sector_explained_coverage = float( sector.get("explained_coverage") if sector.get("explained_coverage") is not None else sector_coverage ) sector_coverage_issue = _sector_coverage_issue( int(sector.get("member_count") or 0), int(sector.get("quote_count") or 0), sector_explained_coverage, sector_explained_count, ) if not sector: issues.append("行业层缺少申万二级行业归属") elif sector.get("taxonomy") != "sw_l2": issues.append("行业层必须使用申万二级行业分类") elif sector_date != trade_date: issues.append("行业行情与目标交易日不一致") elif intraday and not sector.get("realtime"): issues.append("盘中行业层缺少申万实时行情") elif market_mode == "historical" and sector.get("realtime"): issues.append("历史行业层不能使用实时快照") elif closed and sector.get("realtime") and not sector.get("finalized"): issues.append("收盘行业层缺少15:00最终快照") if not sector.get("inner_precise", sector.get("precise")): issues.append("行业内核缺少可核验的成分行情") if not sector.get("outer_precise", sector.get("precise")): issues.append("行业外显缺少申万官方行情") if sector and sector_coverage_issue: issues.append(sector_coverage_issue) if sector.get("realtime") and not sector.get("relative_turnover"): issues.append("行业内核缺少相对全市场换手活跃度") stock = stock or {} stock_date = str(stock.get("trade_date") or "").replace("-", "") if not stock or not stock.get("code"): issues.append("个股层尚未载入有效标的") elif not stock.get("precise"): issues.append("个股层缺少可核验的行情数据") elif stock_date != trade_date: issues.append("个股行情与目标交易日不一致") elif intraday and not stock.get("realtime"): issues.append("盘中个股层不是 rt_k 实时行情") elif not intraday and ( stock.get("realtime") or str(stock.get("data_source") or "") != "tushare" ): issues.append("历史/收盘个股层必须使用 Tushare 官方日线") if intraday and stock and not stock.get("turnover_source"): issues.append("个股内核缺少可核验的实时换手率") elif intraday and stock.get("turnover_source") == "unavailable": issues.append("个股内核缺少流通股本,无法计算实时换手率") if intraday and stock.get("activity_source") == "unavailable": issues.append("个股内核缺少近5日量能基准") elif intraday and not stock.get("activity_source"): issues.append("个股内核缺少同时间进度量能") return issues def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]: trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) field = build_five_phase_field( trade_date, self.database.list_sector_phase_overrides(), ) personal = self.account_personal_field(trade_date, field, public=True) if not personal: raise ValueError("请先在账号设置中保存个人命理资料。") return personal def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]: if not isinstance(raw_lines, list): raise ValueError("六爻起卦结果格式不正确。") try: lines = [int(value) for value in raw_lines] except (TypeError, ValueError) as exc: raise ValueError("六爻必须由六、七、八、九组成。") from exc return hexagram_from_lines(lines) def heaven_readings( self, mode: str, context_date: str = "", limit: int = 100 ) -> dict[str, Any]: mode = str(mode or "").strip() if mode not in {"trend", "fortune", "heart"}: raise ValueError("解读记录类型不正确。") normalized_date = normalize_date(context_date) if context_date else "" return { "mode": mode, "items": self.database.list_heaven_readings( self.current_user_id, mode, normalized_date, limit ), } @staticmethod def _heaven_reading_identity( mode: str, context_date: str, context: dict[str, Any] ) -> tuple[str, str]: display_date = MarketServiceMixin._display_compact_date(context_date) if mode == "trend": stock = (context.get("selected_focus") or {}).get("stock") or {} code = str(stock.get("code") or "").strip() name = str(stock.get("name") or "").strip() hexagram = context.get("hexagram") or {} transformed = hexagram.get("transformed") or {} subject = " ".join(item for item in (code, name) if item) or "观势" detail = f"{display_date} · {hexagram.get('name') or '--'} → {transformed.get('name') or '--'}" return subject, detail if mode == "fortune": field = context.get("five_phase_field") or {} pillars = field.get("pillars") or {} dominant = (field.get("balance") or [{}])[0] subject = f"{display_date} 观气" detail = ( f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · " f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显" ) return subject, detail hexagram = context.get("hexagram") or {} transformed = hexagram.get("transformed") or {} return ( f"{display_date} 观心", f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}", ) def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]: mode = str(payload.get("mode") or "").strip() if mode not in {"trend", "fortune", "heart"}: raise ValueError("问天解读模式不正确。") trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) if mode == "fortune": existing = self.database.latest_heaven_reading( self.current_user_id, "fortune", trade_date ) if self._legacy_truncated_heaven_reading(existing): self.database.delete_heaven_reading( self.current_user_id, int(existing["id"]) ) existing = None if existing: return { "answer": existing["answer"], "mode": mode, "compiler": "stored", "notice": "", "reading": existing, "reused": True, } if mode in {"trend", "fortune"}: setup = self.heaven_setup( trade_date, str(payload.get("sector") or ""), str(payload.get("stock_code") or ""), payload.get("manual_data"), ) if mode == "trend": chart = setup["chart"] if not chart.get("available"): issues = ";".join((chart.get("quality") or {}).get("issues") or []) raise ValueError(f"观势数据未通过六爻校验,暂不解势:{issues}") hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False)) for line in hexagram_context.get("lines", []): line.pop("evidence", None) line.pop("score", None) line.pop("talent", None) line.pop("layer", None) line.pop("role", None) if not line.get("moving"): line.pop("text", None) line.pop("image", None) line.pop("line_name", None) context = { "data_trade_date": setup["trade_date"], "selected_focus": { "sector": chart.get("sector") or "", "stock": chart.get("stock") or {}, }, "hexagram": hexagram_context, "movement": chart.get("movement") or {}, } else: personal_profile = self.account_personal_field( setup["calendar_date"], setup["field"], public=False, ) fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False)) catalog = fortune_field.pop("sector_catalog", []) dominant_elements = { item.get("element") for item in fortune_field.get("balance", [])[:2] } fortune_field["industry_affinity"] = [ { "element": group.get("element"), "examples": [ item.get("name") for item in group.get("industries", [])[:8] if item.get("name") ], } for group in catalog if group.get("element") in dominant_elements ] context = { "calendar_date": setup["calendar_date"], "five_phase_field": fortune_field, "personal_profile": personal_profile, } context_date = setup["calendar_date"] if mode == "trend": context_date = setup["trade_date"] else: context = { "hexagram": self.heaven_hexagram(payload.get("lines")), "ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。", } context_date = trade_date result, compiler = self._call_heaven_agent(mode, context) subject, subject_detail = self._heaven_reading_identity( mode, context_date, context ) dedupe_key = ( f"fortune:{context_date}" if mode == "fortune" else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}" ) reading = self.database.save_heaven_reading( self.current_user_id, mode, context_date, subject, subject_detail, str(result.get("answer") or ""), context, dedupe_key, ) return { **result, "mode": mode, "compiler": compiler, "notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "", "reading": reading, "reused": False, } @staticmethod def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool: return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……")) def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]: result = self.llm_gateway.call( f"heaven_{mode}", f"heaven-{mode}-v1", lambda profile: interpret_heaven( mode, context, profile.api_key, profile.base_url, profile.model, ), (HeavenAgentError,), ) return result.value, result.role 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