from __future__ import annotations from datetime import datetime from typing import Any from backend.bootstrap.config import normalize_date from backend.data.providers.tushare_client import _sector_coverage_issue from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram class HeavenTrendMixin: 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._reusable_daily_fortune_reading(normalized_date) 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 _reusable_daily_fortune_reading( self, context_date: str ) -> dict[str, Any] | None: reading = self.database.latest_heaven_reading( self.current_user_id, "fortune", context_date ) if not reading or self._legacy_truncated_heaven_reading(reading): return None version = self.database.heaven_reading_interpretation_version( self.current_user_id, int(reading["id"]) ) if version != HEAVEN_PROMPT_VERSIONS["fortune"]: return None return reading @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