diff --git a/app/backend/application.py b/app/backend/application.py index 4b968b4..cb4cf6c 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy import json import re import secrets @@ -21,6 +20,7 @@ from backend.llm import LLMGateway, LLMGatewayError from backend.llm.http import LLMHttpMixin from backend.llm.service import LLMServiceMixin from backend.features.market import ChartDataError, MarketServiceMixin +from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field from backend.bootstrap.config import ( DATA_DIR, MENTOR_SKILLS_DIR, @@ -32,15 +32,6 @@ from backend.bootstrap.config import ( validate_text, ) from database import ReviewDatabase -from heaven_agent import HeavenAgentError, interpret_heaven -from heaven_engine import ( - _market_line_scores, - _score_to_line, - build_five_phase_field, - build_market_hexagram, - build_personal_field, - hexagram_from_lines, -) from backend.features.accounts.http import AccountHttpMixin from backend.features.accounts.security import SecretVault from backend.features.accounts.service import AccountService @@ -58,7 +49,7 @@ from backend.features.screener.service import ( from backend.features.sentiment import SentimentServiceMixin from backend.features.system import SystemHttpMixin from backend.features.themes import ThemeServiceMixin -from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue +from backend.data.providers.tushare_client import TushareError LEGACY_SECRET_KEYS = { @@ -87,6 +78,7 @@ class DashboardService( DragonTigerServiceMixin, ScreenerServiceMixin, MentorServiceMixin, + HeavenServiceMixin, LLMServiceMixin, ): def __init__(self) -> None: @@ -713,1283 +705,6 @@ class DashboardService( - @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 = DashboardService._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 - SERVICE = DashboardService() @@ -1998,6 +713,7 @@ class RequestHandler( AccountHttpMixin, SystemHttpMixin, MentorHttpMixin, + HeavenHttpMixin, LLMHttpMixin, HttpTransportMixin, BaseHTTPRequestHandler, @@ -2733,28 +1449,3 @@ class RequestHandler( self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) except Exception as exc: self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR) - - - def heaven_hexagram(self) -> None: - try: - body = self.read_json_body() - result = SERVICE.heaven_hexagram(body.get("lines")) - self.send_json({"ok": True, "hexagram": result}) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - - def heaven_personal(self) -> None: - try: - body = self.read_json_body() - result = SERVICE.heaven_personal(body) - self.send_json({"ok": True, "personal": result}) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - - def heaven_interpret(self) -> None: - try: - body = self.read_json_body() - result = SERVICE.heaven_interpret(body) - self.send_json({"ok": True, **result}) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) diff --git a/app/backend/features/heaven/__init__.py b/app/backend/features/heaven/__init__.py new file mode 100644 index 0000000..81b2073 --- /dev/null +++ b/app/backend/features/heaven/__init__.py @@ -0,0 +1,24 @@ +from .agent import HeavenAgentError, interpret_heaven +from .engine import ( + build_five_phase_field, + build_market_hexagram, + build_manual_market_hexagram, + build_personal_field, + hexagram_from_lines, +) +from .http import HeavenHttpMixin +from .repository import HeavenRepositoryMixin +from .service import HeavenServiceMixin + +__all__ = [ + "HeavenAgentError", + "HeavenHttpMixin", + "HeavenRepositoryMixin", + "HeavenServiceMixin", + "build_five_phase_field", + "build_manual_market_hexagram", + "build_market_hexagram", + "build_personal_field", + "hexagram_from_lines", + "interpret_heaven", +] diff --git a/app/backend/features/heaven/agent.py b/app/backend/features/heaven/agent.py new file mode 100644 index 0000000..e4d5fcc --- /dev/null +++ b/app/backend/features/heaven/agent.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from typing import Any + + +class HeavenAgentError(RuntimeError): + pass + + +def interpret_heaven( + mode: str, + context: dict[str, Any], + api_key: str, + base_url: str, + model: str, + timeout: int = 90, +) -> dict[str, Any]: + if mode not in {"trend", "fortune", "heart"}: + raise HeavenAgentError("不支持的问天解读模式。") + if not api_key or not model: + raise HeavenAgentError("LLM API Key 或模型尚未配置。") + system_prompt = _system_prompt(mode) + payload = json.dumps( + { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(context, ensure_ascii=False, separators=(",", ":")), + }, + ], + "stream": False, + }, + ensure_ascii=False, + ).encode("utf-8") + request = urllib.request.Request( + f"{base_url.rstrip('/')}/chat/completions", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "XiaobaiReviewWeb/0.7", + }, + method="POST", + ) + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + result = json.loads(response.read().decode("utf-8")) + answer = str(result["choices"][0]["message"]["content"]).strip() + if not answer: + raise KeyError("empty response") + except urllib.error.HTTPError as exc: + raise HeavenAgentError(_http_error_message(exc)) from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: + raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc + return { + "answer": answer, + "model": model, + "latency_ms": round((time.perf_counter() - started) * 1000), + } + + +def _system_prompt(mode: str) -> str: + common = """ +你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。 +问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。 +使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。 +""".strip() + if mode == "trend": + return common + """ + +当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。 +行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。 +先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。 +重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。 +全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。 +不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。 +""".strip() + if mode == "fortune": + return common + """ + +当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。 +严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。 +重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。 +首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。 +如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。 +不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。 +全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。 +""".strip() + return common + """ + +当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。 +全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。 +不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。 +""".strip() + + +def _http_error_message(exc: urllib.error.HTTPError) -> str: + detail = "" + try: + payload = json.loads(exc.read().decode("utf-8", errors="replace")) + error = payload.get("error") + if isinstance(error, dict): + detail = str(error.get("message") or error.get("code") or "") + elif error: + detail = str(error) + elif payload.get("message"): + detail = str(payload["message"]) + except (json.JSONDecodeError, OSError): + detail = "" + suffix = f":{detail[:300]}" if detail else "" + return f"问天模型调用失败(HTTP {exc.code}){suffix}" diff --git a/app/backend/features/heaven/engine.py b/app/backend/features/heaven/engine.py new file mode 100644 index 0000000..b6cd0bf --- /dev/null +++ b/app/backend/features/heaven/engine.py @@ -0,0 +1,1181 @@ +from __future__ import annotations + +import json +import math +import sys +from datetime import datetime +from functools import lru_cache +from typing import Any + +from backend.bootstrap.config import APP_DIR + +VENDOR_DIR = APP_DIR / "vendor" +ICHING_DATA_FILE = APP_DIR / "data" / "iching_zh.json" +if str(VENDOR_DIR) not in sys.path: + sys.path.insert(0, str(VENDOR_DIR)) + +from lunar_python import Solar # noqa: E402 +from lunar_python.util import LunarUtil # noqa: E402 + + +TRIGRAM_NAMES = { + (1, 1, 1): "乾", + (1, 1, 0): "兑", + (1, 0, 1): "离", + (1, 0, 0): "震", + (0, 1, 1): "巽", + (0, 1, 0): "坎", + (0, 0, 1): "艮", + (0, 0, 0): "坤", +} + +LINE_POSITIONS = ("初爻", "二爻", "三爻", "四爻", "五爻", "上爻") +LINE_ROLES = ( + ("地", "内", "个股内核"), + ("地", "外", "个股外显"), + ("人", "内", "行业内核"), + ("人", "外", "行业外显"), + ("天", "内", "指数内核"), + ("天", "外", "指数外显"), +) + +STEM_MOVEMENT = { + "甲": "土", "己": "土", + "乙": "金", "庚": "金", + "丙": "水", "辛": "水", + "丁": "木", "壬": "木", + "戊": "火", "癸": "火", +} +MOVEMENT_PAIR = { + "土": "甲己化土", + "金": "乙庚化金", + "水": "丙辛化水", + "木": "丁壬化木", + "火": "戊癸化火", +} +YANG_STEMS = set("甲丙戊庚壬") +STEM_ELEMENT = { + "甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", + "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水", +} +BRANCH_ELEMENT = { + "子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", "巳": "火", + "午": "火", "未": "土", "申": "金", "酉": "金", "戌": "土", "亥": "水", +} +SUIHUI_BRANCHES = set("子丑卯辰午未酉戌") +SITIAN = { + "子": "少阴君火", "午": "少阴君火", + "丑": "太阴湿土", "未": "太阴湿土", + "寅": "少阳相火", "申": "少阳相火", + "卯": "阳明燥金", "酉": "阳明燥金", + "辰": "太阳寒水", "戌": "太阳寒水", + "巳": "厥阴风木", "亥": "厥阴风木", +} +ZAIQUAN = { + "少阴君火": "阳明燥金", + "太阴湿土": "太阳寒水", + "少阳相火": "厥阴风木", + "阳明燥金": "少阴君火", + "太阳寒水": "太阴湿土", + "厥阴风木": "少阳相火", +} +# 客气次序(一阴→二阴→三阴→一阳→二阳→三阳)。 +QI_SEQUENCE = ("厥阴风木", "少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水") +# 主气次序(固定,按五行相生:木→君火→相火→湿土→燥金→寒水)。 +HOST_QI_SEQUENCE = ("厥阴风木", "少阴君火", "少阳相火", "太阴湿土", "阳明燥金", "太阳寒水") +QI_ELEMENT = { + "厥阴风木": "木", "少阴君火": "火", "太阴湿土": "土", + "少阳相火": "火", "阳明燥金": "金", "太阳寒水": "水", +} +STEP_NAMES = ("初之气", "二之气", "三之气", "四之气", "五之气", "终之气") +PHASE_INFO = { + "木": {"motion": "生发、扩散、延展", "mind": "更愿意尝试新方向,也容易高估成长斜率"}, + "火": {"motion": "显化、加速、躁动", "mind": "注意力集中、追逐速度,也容易冲动和过度一致"}, + "土": {"motion": "承载、黏合、迟滞", "mind": "偏好确定和稳定,也可能出现犹豫与路径依赖"}, + "金": {"motion": "收敛、裁决、肃降", "mind": "纪律和风险意识增强,也容易形成快速杀估值"}, + "水": {"motion": "流动、潜藏、下行", "mind": "资金更重视流动性和退路,也可能放大恐惧传染"}, +} +PHASE_BEHAVIOR = { + "木": { + "emotion": "求新与扩张感增强,容易对新题材迅速产生期待", + "bias": "倾向先看到成长空间,再补风险验证", + "operation": "更想试仓、开新方向或给趋势更高估值", + "risk": "防止把萌芽当成主升,把想象力当成确认", + "balance": "先写清验证条件,等分歧后的承接再决定是否加码", + }, + "火": { + "emotion": "兴奋、急迫和表现欲更容易被放大,群体注意力趋于集中", + "bias": "倾向追逐速度与一致性,低估高位拥挤和冲动成本", + "operation": "更容易追涨、抢先手、放宽原有仓位上限", + "risk": "防止情绪高潮时把一致误作确定,把速度误作安全", + "balance": "延迟一次下单冲动,用成交承接和次日反馈替代情绪确认", + }, + "土": { + "emotion": "对确定性和安全感的需求上升,也容易迟疑、黏滞", + "bias": "倾向依赖熟悉路径,对已经持有的判断更难松手", + "operation": "更容易守仓、等确认,或因不愿认错而延迟处理", + "risk": "防止把稳定感当作低风险,把犹豫当作耐心", + "balance": "把持仓理由量化,触发失效条件时按计划减法处理", + }, + "金": { + "emotion": "警觉、挑剔和裁决感增强,容错意愿下降", + "bias": "倾向快速分辨强弱,也可能过早否定尚在修复的机会", + "operation": "更容易止损、兑现、收缩仓位并集中到辨识度高的标的", + "risk": "防止在恐慌扩散时机械割裂,也防止过度追求完美买点", + "balance": "区分逻辑失效与价格波动,给修复保留一个观察窗口", + }, + "水": { + "emotion": "不确定感与避险意识上升,消息和恐惧更容易传染", + "bias": "倾向先寻找退路,可能放大流动性风险或反复试探", + "operation": "更容易降仓、观望、快进快出,偏好有流动性的方向", + "risk": "防止因想象最坏结果而在低流动性时点失去判断", + "balance": "降低频率,保留现金与预案,只处理能清楚定义风险的交易", + }, +} +ELEMENT_GENERATES = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} +ELEMENT_CONTROLS = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"} +SECTOR_PHASE_RULES = { + "木": ( + # 植物生长类 + 仁术(医) + 教化(教育) + 纤维文书 + "农业", "种植", "种业", "林业", "园林", "畜牧", "养殖", "饲料", + "医药", "中药", "生物医药", "创新药", "医疗", "疫苗", + "教育", "培训", "出版", "图书", + "纺织", "服装", "服饰", "家纺", "造纸", "印刷", "包装", + "家具", "家居", "木材", "烟草", + ), + "火": ( + # 光热能源 + 电子传媒 + 炉灶 + "电力", "火电", "光伏", "太阳能", "风电", "储能", "电池", "锂电", + "充电桩", "新能源", "核电", "煤炭", "石油", "石化", "燃气", + "电子", "半导体", "芯片", "集成电路", "消费电子", "光学", "光电", + "显示", "面板", "通信", "计算机", "软件", "互联网", "游戏", + "人工智能", "数据", "云计算", "传媒", "影视", "广告", "娱乐", "直播", + ), + "土": ( + # 不动产 + 营造 + 稼穑饮食(土主养育) + "地产", "房地产", "物业", "建筑", "基建", "工程", "路桥", + "建材", "水泥", "玻璃", "陶瓷", "混凝土", "管材", "防水", + "食品", "乳业", "肉制品", "调味品", "农产品加工", + "零售", "百货", "仓储", + ), + "金": ( + # 金属机械 + 财帛裁决 + 兵戈肃杀 + "银行", "证券", "保险", "期货", "信托", "金融", "支付", + "钢铁", "有色", "金属", "贵金属", "黄金", "稀土", + "机械", "设备", "机床", "机器人", "仪器", "仪表", + "汽车", "整车", "零部件", "家电", "五金", + "军工", "国防", "兵器", "船舶", "航天", + ), + "水": ( + # 流动运输 + 液体 + 商旅(水主流、主智) + "航运", "港口", "物流", "快递", "运输", "航空", "机场", + "水务", "供水", "污水", "水利", "环保", + "饮料", "白酒", "啤酒", "黄酒", + "化工", "化学", "化纤", + "旅游", "酒店", "餐饮", "水产", "渔业", "贸易", "商贸", + ), +} + + +def build_market_hexagram( + dashboard: dict[str, Any], + recent_history: list[dict[str, Any]], + index_context: dict[str, Any] | None = None, + sector_name: str = "", + stock_code: str = "", + external_stock: dict[str, Any] | None = None, + external_sector: dict[str, Any] | None = None, +) -> dict[str, Any]: + sectors = list(dashboard.get("sectors") or []) + limits = list(dashboard.get("limits") or []) + broken = list(dashboard.get("broken") or []) + down_limits = list(dashboard.get("down_limits") or []) + normalized_sector = sector_name.strip().lower() + external_sector = external_sector or {} + selected_sector = next( + ( + item for item in sectors + if str(item.get("name") or "").strip().lower() == normalized_sector + or (normalized_sector and normalized_sector in str(item.get("name") or "").strip().lower()) + ), + None, + ) + if external_sector: + selected_sector = external_sector + external_stock = external_stock or {} + external_stock_sector = str(external_stock.get("sector") or "").strip() + if selected_sector is None and external_stock_sector: + selected_sector = next((item for item in sectors if item.get("name") == external_stock_sector), None) + if selected_sector is None and external_stock: + selected_sector = { + "name": external_stock_sector or sector_name.strip() or "个股所属行业", + "leader": external_stock.get("name") or "--", + "change": external_stock.get("change") or 0, + "strength": max(0, min(100, 50 + float(external_stock.get("change") or 0) * 3)), + "amount_billion": external_stock.get("amount_billion") or 0, + "count": 0, + "max_streak": 0, + } + selected_sector = selected_sector or (sectors[0] if sectors else {}) + actual_sector = str(selected_sector.get("name") or "暂无热点") + sector_stocks = [row for row in limits + broken + down_limits if row.get("sector") == actual_sector] + selected_stock = next((row for row in sector_stocks if str(row.get("code")) == stock_code), None) + if selected_stock is None and external_stock: + selected_stock = external_stock + if selected_stock is None and selected_sector.get("leader"): + selected_stock = next( + (row for row in sector_stocks if row.get("name") == selected_sector.get("leader")), + None, + ) + selected_stock = selected_stock or (sector_stocks[0] if sector_stocks else (limits[0] if limits else {})) + + scores = _market_line_scores( + dashboard, + recent_history, + index_context or {}, + selected_sector, + selected_stock, + limits, + ) + values = [_score_to_line(item["score"]) for item in scores] + hexagram = hexagram_from_lines(values) + for index, (line, score) in enumerate(zip(hexagram["lines"], scores)): + talent, layer, role = LINE_ROLES[index] + line.update( + { + "talent": talent, + "layer": layer, + "role": role, + "score": round(score["score"], 3), + "evidence": score["evidence"], + } + ) + pair_readings = [] + for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)): + inner = scores[inner_index]["score"] + outer = scores[outer_index]["score"] + if inner >= 0 and outer >= 0: + state = "内外相应,势有承载" + elif inner < 0 <= outer: + state = "外强内弱,表里有差" + elif inner >= 0 > outer: + state = "内强外抑,势待显化" + else: + state = "内外皆弱,宜守不宜躁" + pair_readings.append({"level": label, "state": state, "inner": round(inner, 3), "outer": round(outer, 3)}) + + options = [] + for sector in sectors[:20]: + name = str(sector.get("name") or "") + stocks = [row for row in limits + broken + down_limits if row.get("sector") == name] + options.append( + { + "name": name, + "leader": sector.get("leader") or "", + "stocks": [ + {"code": str(row.get("code") or ""), "name": row.get("name") or "--", "status": row.get("status") or ""} + for row in stocks[:20] + ], + } + ) + average_score = sum(item["score"] for item in scores) / 6 + moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]] + movement = { + "moving_lines": hexagram["moving_lines"], + "moving_names": moving_names, + "label": ( + f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}" + if moving_names + else f"无动爻,守{hexagram['name']}本势" + ), + "explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。", + } + return { + "data_trade_date": str(dashboard.get("meta", {}).get("trade_date") or ""), + "sector": actual_sector, + "sector_code": str(selected_sector.get("code") or ""), + "sector_taxonomy": str(selected_sector.get("taxonomy") or ""), + "stock": { + "code": str(selected_stock.get("code") or ""), + "name": selected_stock.get("name") or "--", + "status": selected_stock.get("status") or "", + }, + "selection_notice": "", + "hexagram": hexagram, + "movement": movement, + "pair_readings": pair_readings, + "momentum_score": round(average_score * 100), + "momentum_label": _momentum_label(average_score), + "sector_options": options, + "index_context": index_context or {}, + } + + +def build_manual_market_hexagram( + values: list[int], + data_trade_date: str, + sector: dict[str, Any] | None, + stock: dict[str, Any] | None, + index_context: dict[str, Any] | None = None, + note: str = "", +) -> dict[str, Any]: + """Build an explicitly user-calibrated chart without pretending it is market data.""" + hexagram = hexagram_from_lines(values) + score_map = {6: -0.85, 8: -0.35, 7: 0.35, 9: 0.85} + scores = [score_map[value] for value in values] + value_names = {6: "老阴·动", 8: "少阴·静", 7: "少阳·静", 9: "老阳·动"} + for index, line in enumerate(hexagram["lines"]): + talent, layer, role = LINE_ROLES[index] + line.update( + { + "talent": talent, + "layer": layer, + "role": role, + "score": scores[index], + "evidence": [f"用户手动校准为{value_names[values[index]]}"], + } + ) + + pair_readings = [] + for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)): + inner, outer = scores[inner_index], scores[outer_index] + if inner >= 0 and outer >= 0: + state = "内外相应,势有承载" + elif inner < 0 <= outer: + state = "外强内弱,表里有差" + elif inner >= 0 > outer: + state = "内强外抑,势待显化" + else: + state = "内外皆弱,宜守不宜躁" + pair_readings.append({"level": label, "state": state, "inner": inner, "outer": outer}) + + moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]] + movement = { + "moving_lines": hexagram["moving_lines"], + "moving_names": moving_names, + "label": ( + f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}" + if moving_names else f"无动爻,守{hexagram['name']}本势" + ), + "explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。", + } + average_score = sum(scores) / 6 + sector = sector or {} + stock = stock or {} + return { + "data_trade_date": data_trade_date, + "sector": str(sector.get("name") or stock.get("sector") or "--"), + "sector_code": str(sector.get("code") or ""), + "sector_taxonomy": str(sector.get("taxonomy") or ""), + "stock": { + "code": str(stock.get("code") or ""), + "name": str(stock.get("name") or "--"), + "status": str(stock.get("status") or ""), + }, + "selection_notice": "", + "hexagram": hexagram, + "movement": movement, + "pair_readings": pair_readings, + "momentum_score": round(average_score * 100), + "momentum_label": _momentum_label(average_score), + "sector_options": [], + "index_context": index_context or {}, + "manual_calibration": True, + "calibration_note": note, + } + + +def build_five_phase_field( + trade_date: str, + sector_phase_overrides: dict[str, str] | None = None, +) -> dict[str, Any]: + """构建指定日期的五运六气场。 + + 本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换; + 大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。 + """ + compact = trade_date.replace("-", "") + if len(compact) != 8 or not compact.isdigit(): + raise ValueError("日期格式应为 YYYY-MM-DD。") + year, month, day = int(compact[:4]), int(compact[4:6]), int(compact[6:]) + # 公共气场以日期为最小粒度。固定取正午只为构造历法对象,不引入时辰权重。 + solar = Solar.fromYmdHms(year, month, day, 12, 0, 0) + lunar = solar.getLunar() + year_gz = lunar.getYearInGanZhiExact() + month_gz = lunar.getMonthInGanZhiExact() + day_gz = lunar.getDayInGanZhiExact() + year_stem, year_branch = year_gz[0], year_gz[1] + movement_phase = STEM_MOVEMENT[year_stem] + movement_tendency = "太过" if year_stem in YANG_STEMS else "不及" + sitian = SITIAN[year_branch] + zaiquan = ZAIQUAN[sitian] + step = _current_qi_step(lunar, solar.toYmd()) + host_qi = HOST_QI_SEQUENCE[step - 1] + sitian_index = QI_SEQUENCE.index(sitian) + guest_qi = QI_SEQUENCE[(sitian_index - 2 + step - 1) % 6] + prev_jie_qi = lunar.getPrevJieQi() + next_jie_qi = lunar.getNextJieQi() + + # 年纲由中运与岁气共同建立。岁半以前司天为主,岁半以后在泉为主; + # 另一端仍保留背景作用,避免把天地升降误解为截然切断。 + sitian_weight, zaiquan_weight = (15, 5) if step <= 3 else (5, 15) + year_weights = {element: 0.0 for element in PHASE_INFO} + _add_phase(year_weights, movement_phase, 30) + _add_phase(year_weights, QI_ELEMENT[sitian], sitian_weight) + _add_phase(year_weights, QI_ELEMENT[zaiquan], zaiquan_weight) + + current_qi_weights = {element: 0.0 for element in PHASE_INFO} + _add_phase(current_qi_weights, QI_ELEMENT[host_qi], 20) + _add_phase(current_qi_weights, QI_ELEMENT[guest_qi], 25) + + day_weights = {element: 0.0 for element in PHASE_INFO} + _add_phase(day_weights, STEM_MOVEMENT[day_gz[0]], 2.5) + _add_phase(day_weights, BRANCH_ELEMENT[day_gz[1]], 2.5) + + weights = { + element: year_weights[element] + current_qi_weights[element] + day_weights[element] + for element in PHASE_INFO + } + total = sum(weights.values()) or 1 + balance = [ + { + "element": element, + "score": score, + "percent": round(score / total * 100), + **PHASE_INFO[element], + } + for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True) + ] + overrides = sector_phase_overrides or {} + sector_catalog = _sector_phase_catalog(overrides) + dominant = balance[0] + secondary = balance[1] + year_dominant = _dominant_phase(year_weights) + current_qi_dominant = _dominant_phase(current_qi_weights) + day_dominant = _dominant_phase(day_weights) + guest_host_relation = _guest_host_relation(host_qi, guest_qi) + annual_pattern = _annual_qi_pattern( + movement_phase, + QI_ELEMENT[sitian], + year_branch, + ) + annual_pattern_suffix = f";{annual_pattern['primary']}" if annual_pattern["primary"] else "" + ruling_qi = sitian if step <= 3 else zaiquan + ruling_label = "司天" if step <= 3 else "在泉" + alignment = "" + if guest_qi == sitian: + alignment = "司天同位" + elif guest_qi == zaiquan: + alignment = "在泉同位" + dominant_behavior = PHASE_BEHAVIOR[dominant["element"]] + secondary_behavior = PHASE_BEHAVIOR[secondary["element"]] + calendar_date = f"{year:04d}-{month:02d}-{day:02d}" + human_field = { + "summary": ( + f"年以{year_dominant}为纲,当前{STEP_NAMES[step - 1]}由{ruling_label}{ruling_qi}主其半岁," + f"客主呈{guest_host_relation['label']},日由{day_dominant}触发;" + f"合看以{dominant['element']}气偏显、{secondary['element']}气相随。{dominant_behavior['emotion']}。" + ), + "emotional_tendency": [dominant_behavior["emotion"], secondary_behavior["emotion"]], + "decision_biases": [dominant_behavior["bias"], secondary_behavior["bias"]], + "operation_tendency": dominant_behavior["operation"], + "risk_reminders": [dominant_behavior["risk"], secondary_behavior["risk"]], + "balancing_actions": [dominant_behavior["balance"], secondary_behavior["balance"]], + } + return { + "date": calendar_date, + "lunar_date": f"农历{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}", + "pillars": {"year": year_gz, "month": month_gz, "day": day_gz}, + "movement": { + "phase": movement_phase, + "tendency": movement_tendency, + "label": f"{movement_phase}运{movement_tendency}", + "basis": f"{year_stem}属{movement_phase}运,{year_stem}为{'阳干' if year_stem in YANG_STEMS else '阴干'}", + }, + "six_qi": { + "sitian": sitian, + "zaiquan": zaiquan, + "step": step, + "step_name": STEP_NAMES[step - 1], + "host_qi": host_qi, + "guest_qi": guest_qi, + "ruling": ruling_label, + "ruling_qi": ruling_qi, + "alignment": alignment, + }, + "solar_terms": { + "current": prev_jie_qi.getName(), + "current_at": prev_jie_qi.getSolar().toYmdHms(), + "next": next_jie_qi.getName(), + "next_at": next_jie_qi.getSolar().toYmdHms(), + }, + "framework": { + "principle": "先立年纲,再察客气加临主气;岁半以前司天为主,岁半以后在泉为主,日辰只作触发。六气自大寒步进,岁气以立春为界。", + "weights": { + "year_movement": 30, + "sitian_zaiquan": 20, + "sitian": sitian_weight, + "zaiquan": zaiquan_weight, + "host_qi": 20, + "guest_qi": 25, + "day": 5, + }, + "relations": { + "guest_host": guest_host_relation, + "annual_pattern": annual_pattern, + "alignment": alignment, + "ruling": { + "label": ruling_label, + "qi": ruling_qi, + "summary": f"当前由{ruling_label}{ruling_qi}主其半岁,另一端退居背景。", + }, + }, + "layers": [ + { + "id": "year", + "label": "年纲", + "weight": 50, + "dominant": year_dominant, + "summary": ( + f"{MOVEMENT_PAIR[movement_phase]},{movement_phase}运{movement_tendency};" + f"{ruling_label}{ruling_qi}当权" + f"{annual_pattern_suffix}" + ), + "balance": _phase_distribution(year_weights), + }, + { + "id": "current", + "label": "客主加临", + "weight": 45, + "dominant": current_qi_dominant, + "summary": ( + f"当前{STEP_NAMES[step - 1]},客{guest_qi}加临主{host_qi};" + f"{guest_host_relation['label']},{guest_host_relation['tendency']}" + ), + "balance": _phase_distribution(current_qi_weights), + }, + { + "id": "day", + "label": "日辰触发", + "weight": 5, + "dominant": day_dominant, + "summary": f"{day_gz}日,{_movement_label(day_gz[0])};{day_gz[1]}属{BRANCH_ELEMENT[day_gz[1]]}、应{SITIAN[day_gz[1]]}", + "balance": _phase_distribution(day_weights), + }, + ], + }, + "balance": balance, + "human_field": human_field, + "sector_catalog": sector_catalog, + "notice": "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。", + } + + +def build_personal_field( + birth_datetime: str, + gender: str, + current_date: str, + current_field: dict[str, Any] | None = None, +) -> dict[str, Any]: + try: + born = datetime.strptime(birth_datetime, "%Y-%m-%dT%H:%M") + except ValueError as exc: + raise ValueError("出生时间格式应为 YYYY-MM-DDTHH:MM。") from exc + if not 1900 <= born.year <= 2100: + raise ValueError("出生年份应在 1900 至 2100 年之间。") + if gender not in {"male", "female", "unspecified"}: + raise ValueError("性别选项不正确。") + + solar = Solar.fromYmdHms(born.year, born.month, born.day, born.hour, born.minute, 0) + lunar = solar.getLunar() + eight = lunar.getEightChar() + pillars = { + "year": eight.getYear(), + "month": eight.getMonth(), + "day": eight.getDay(), + "time": eight.getTime(), + } + visible_elements = {element: 0.0 for element in PHASE_INFO} + for key, pillar in pillars.items(): + visible_elements[STEM_ELEMENT[pillar[0]]] += 1 + visible_elements[BRANCH_ELEMENT[pillar[1]]] += 1.5 if key == "month" else 1 + total = sum(visible_elements.values()) or 1 + element_balance = [ + {"element": element, "score": round(score, 1), "percent": round(score / total * 100)} + for element, score in sorted(visible_elements.items(), key=lambda item: item[1], reverse=True) + ] + + day_master = eight.getDayGan() + day_element = STEM_ELEMENT[day_master] + resource_element = next(element for element, generated in ELEMENT_GENERATES.items() if generated == day_element) + output_element = ELEMENT_GENERATES[day_element] + wealth_element = ELEMENT_CONTROLS[day_element] + officer_element = next(element for element, controlled in ELEMENT_CONTROLS.items() if controlled == day_element) + support_score = visible_elements[day_element] + visible_elements[resource_element] + if support_score < total * 0.42: + strength = "偏弱" + favorable = [resource_element, day_element] + caution = [officer_element, wealth_element, output_element] + balance_note = "日主支持偏少,简化算法倾向先取生扶,再看泄耗与制约是否过强。" + elif support_score > total * 0.62: + strength = "偏强" + favorable = [output_element, wealth_element, officer_element] + caution = [day_element, resource_element] + balance_note = "日主支持偏多,简化算法倾向用泄、耗、制来恢复流动。" + else: + strength = "相对平衡" + favorable = [output_element, wealth_element] + caution = [element_balance[0]["element"]] + balance_note = "五行支持与消耗接近,简化算法更看重当下偏盛元素的调节。" + + ten_gods = { + "year": {"stem": eight.getYearShiShenGan(), "branches": eight.getYearShiShenZhi()}, + "month": {"stem": eight.getMonthShiShenGan(), "branches": eight.getMonthShiShenZhi()}, + "day": {"stem": "日主", "branches": eight.getDayShiShenZhi()}, + "time": {"stem": eight.getTimeShiShenGan(), "branches": eight.getTimeShiShenZhi()}, + } + ten_god_roles = { + day_element: "比劫", + resource_element: "印星", + output_element: "食伤", + wealth_element: "财星", + officer_element: "官杀", + } + + compact = current_date.replace("-", "") + if len(compact) != 8 or not compact.isdigit(): + raise ValueError("当前日期格式应为 YYYY-MM-DD。") + current_solar = Solar.fromYmdHms(int(compact[:4]), int(compact[4:6]), int(compact[6:]), 12, 0, 0) + current_lunar = current_solar.getLunar() + current_pillars = { + "year": current_lunar.getYearInGanZhiExact(), + "month": current_lunar.getMonthInGanZhiExact(), + "day": current_lunar.getDayInGanZhiExact(), + } + current_ten_gods = { + key: { + "pillar": pillar, + "stem": LunarUtil.SHI_SHEN.get(day_master + pillar[0]) or "--", + "branches": [LunarUtil.SHI_SHEN.get(day_master + gan) or "--" for gan in LunarUtil.ZHI_HIDE_GAN.get(pillar[1], [])], + } + for key, pillar in current_pillars.items() + } + field = current_field or build_five_phase_field(current_date) + dominant_elements = [item["element"] for item in field.get("balance", [])[:2]] + favorable_hits = [element for element in dominant_elements if element in favorable] + caution_hits = [element for element in dominant_elements if element in caution] + if favorable_hits and not caution_hits: + personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(favorable_hits)}较合你的平衡倾向,主观上更容易感到有支点。" + operation_note = "顺手感可能增强,但仍应把它当作自我状态提醒,不宜因此放宽交易纪律。" + elif caution_hits and not favorable_hits: + personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(caution_hits)}可能放大你的耗泄或压力感。" + operation_note = "更适合降低决策频率,尤其留意急于证明、犹豫不决或过早止损等惯性反应。" + else: + personal_tone = f"当日{'、'.join(dominant_elements)}并见,对你既有助力也有牵制,感受可能随情境切换。" + operation_note = "先辨认自己此刻是兴奋、恐惧还是执着,再决定是否需要行动。" + return { + "birth": {"datetime": birth_datetime, "gender": gender, "lunar": lunar.toString()}, + "pillars": pillars, + "day_master": {"stem": day_master, "element": day_element, "strength": strength}, + "ten_gods": ten_gods, + "ten_god_tendency": { + "favorable": [ten_god_roles[element] for element in favorable], + "caution": [ten_god_roles[element] for element in caution], + }, + "element_balance": element_balance, + "balance_tendency": { + "favorable": favorable, + "caution": caution, + "note": balance_note, + "method": "按可见四柱五行、月令加权及日主生扶比例生成的简化平衡倾向,不等同于专业命理中的唯一喜用神结论。", + }, + "current": { + "date": current_date, + "pillars": current_pillars, + "ten_gods": current_ten_gods, + "tone": personal_tone, + "operation_note": operation_note, + }, + "notice": "个人结果仅供传统文化与自我观察使用。出生信息只在本机服务中计算。", + } + + +def hexagram_from_lines(values: list[int]) -> dict[str, Any]: + if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values): + raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。") + bits = tuple(1 if value % 2 else 0 for value in values) + transformed_values = [7 if value == 6 else 8 if value == 9 else value for value in values] + transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values) + data = _iching_data() + primary = data.get(str(bits)) + transformed = data.get(str(transformed_bits)) + if not primary or not transformed: + raise ValueError("卦象数据不完整。") + lines = [] + line_items = list(primary["lines"].values()) + for index, (value, item) in enumerate(zip(values, line_items)): + lines.append( + { + "position": index + 1, + "position_name": LINE_POSITIONS[index], + "value": value, + "yin_yang": "阳" if value % 2 else "阴", + "moving": value in {6, 9}, + "line_name": item["name"], + "text": item["text"], + "image": item.get("image") or "", + } + ) + inner = TRIGRAM_NAMES[bits[:3]] + outer = TRIGRAM_NAMES[bits[3:]] + transformed_inner = TRIGRAM_NAMES[transformed_bits[:3]] + transformed_outer = TRIGRAM_NAMES[transformed_bits[3:]] + return { + "name": primary["name"], + "text": primary["text"], + "image": primary.get("image") or "", + "inner_trigram": inner, + "outer_trigram": outer, + "lines": lines, + "moving_lines": [index + 1 for index, value in enumerate(values) if value in {6, 9}], + "transformed": { + "name": transformed["name"], + "text": transformed["text"], + "image": transformed.get("image") or "", + "inner_trigram": transformed_inner, + "outer_trigram": transformed_outer, + }, + } + + +def _market_line_scores( + dashboard: dict[str, Any], + recent_history: list[dict[str, Any]], + index_context: dict[str, Any], + sector: dict[str, Any], + stock: dict[str, Any], + limits: list[dict[str, Any]], +) -> list[dict[str, Any]]: + overview = dashboard.get("overview") or {} + stock_amount = float(stock.get("amount_billion") or 0) + stock_intraday = bool(stock.get("realtime")) or stock.get("_quantitative_mode") == "intraday" + if stock_intraday and stock.get("activity_source"): + amount_rank = _clamp(float(stock.get("amount_percentile") or 0) / 100) + turnover_relative = _clamp( + (float(stock.get("turnover_relative") or 0) - 1) / 1.5, + -1, + 1, + ) + volume_activity = _clamp( + (float(stock.get("volume_activity_ratio") or 0) - 1) / 1.5, + -1, + 1, + ) + stock_inner = _clamp( + (amount_rank * 2 - 1) * 0.35 + + turnover_relative * 0.35 + + volume_activity * 0.30, + -1, + 1, + ) + else: + amounts = [float(item.get("amount_billion") or 0) for item in limits] + amount_rank = ( + _clamp(float(stock.get("amount_percentile") or 0) / 100) + if "amount_percentile" in stock + else _percentile(stock_amount, amounts) + ) + turnover = _clamp(float(stock.get("turnover_rate") or 0) / 20) + seal = _clamp(float(stock.get("seal_amount_million") or 0) / 15000) + stability = 1 - _clamp(float(stock.get("open_times") or 0) / 6) + stock_inner_raw = 0.32 * amount_rank + 0.22 * turnover + 0.25 * seal + 0.21 * stability + stock_inner = stock_inner_raw * 2 - 1 + stock_change = _clamp(float(stock.get("change") or 0) / 10, -1, 1) + streak = _clamp(float(stock.get("streak") or 0) / 5) + status_adjustment = -0.7 if stock.get("status") == "跌停" else -0.25 if stock.get("status") == "炸板" else 0.15 + stock_outer = _clamp(stock_change * 0.7 + streak * 0.2 + status_adjustment, -1, 1) + + rotation = next( + (item for item in dashboard.get("sector_rotation") or [] if item.get("name") == sector.get("name")), + {}, + ) + sector_quantitative_mode = str(sector.get("_quantitative_mode") or "") + actual_sector_source = str(sector.get("source") or "").startswith("tushare_") + if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday": + sector_change = float(sector.get("change") or 0) + sector_change_score = _clamp(sector_change / 5, -1, 1) + sector_up = float(sector.get("up_count") or 0) + sector_down = float(sector.get("down_count") or 0) + sector_breadth = _clamp( + (sector_up - sector_down) / max(sector_up + sector_down, 1), -1, 1 + ) + relative_turnover_score = _clamp( + (float(sector.get("relative_turnover") or 0) - 1) / 1.5, + -1, + 1, + ) + leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1) + sector_inner = _clamp( + sector_breadth * 0.60 + relative_turnover_score * 0.40, + -1, + 1, + ) + sector_outer = _clamp( + sector_change_score * 0.90 + leading_score * 0.10, + -1, + 1, + ) + sector_inner_evidence = [ + f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家", + f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍", + ] + sector_outer_evidence = [ + f"申万二级行业官方涨跌 {sector_change:+.2f}%", + f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%", + ] + elif actual_sector_source or sector_quantitative_mode == "historical": + sector_change = float(sector.get("change") or 0) + sector_change_score = _clamp(sector_change / 5, -1, 1) + member_equal_change = float(sector.get("member_equal_change") if sector.get("member_equal_change") is not None else sector_change) + member_change_score = _clamp(member_equal_change / 5, -1, 1) + sector_up = float(sector.get("up_count") or 0) + sector_down = float(sector.get("down_count") or 0) + if sector_up + sector_down: + sector_breadth = _clamp((sector_up - sector_down) / (sector_up + sector_down), -1, 1) + else: + sector_breadth = sector_change_score + leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1) + sector_inner = _clamp(sector_breadth * 0.6 + member_change_score * 0.35 + leading_score * 0.05, -1, 1) + sector_outer = _clamp(sector_change_score * 0.9 + leading_score * 0.1, -1, 1) + sector_inner_evidence = [ + f"行业上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家", + f"行业成分等权涨跌 {member_equal_change:+.2f}%", + ] + sector_outer_evidence = [ + f"{sector.get('name') or '--'}行业涨跌 {sector_change:+.2f}%", + f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%", + ] + else: + max_count = max([float(item.get("count") or 0) for item in dashboard.get("sectors") or []] or [1]) + sector_count = _clamp(float(sector.get("count") or 0) / max_count) + sector_strength = _clamp(float(sector.get("strength") or 0) / 100) + sector_amount = _clamp(float(sector.get("amount_billion") or 0) / 100) + delta = _clamp(float(rotation.get("delta") or 0) / 8, -1, 1) + sector_inner = _clamp((sector_count * 0.35 + sector_strength * 0.35 + sector_amount * 0.2 + (delta + 1) / 2 * 0.1) * 2 - 1) + leader_change = _clamp(float(sector.get("change") or 0) / 10, -1, 1) + max_streak = _clamp(float(sector.get("max_streak") or 0) / 5) + sector_outer = _clamp( + leader_change * 0.45 + sector_strength * 0.25 + max_streak * 0.2 + delta * 0.1, + -1, + 1, + ) + sector_inner_evidence = [ + f"{sector.get('name') or '--'}涨停 {int(sector.get('count') or 0)} 家,强度 {float(sector.get('strength') or 0):.0f}", + f"板块成交 {float(sector.get('amount_billion') or 0):.1f} 亿,家数变化 {float(rotation.get('delta') or 0):+.0f}", + ] + sector_outer_evidence = [ + f"领涨股 {sector.get('leader') or '--'},涨跌 {float(sector.get('change') or 0):+.2f}%", + f"最高 {int(sector.get('max_streak') or 0)} 板,轮动 {rotation.get('trend') or '暂无'}", + ] + + sentiment = _clamp(float(overview.get("sentiment_score") or 0) / 100) + seal_rate = _clamp(float(overview.get("seal_rate") or 0) / 100) + up_count = float(overview.get("up_count") or 0) + down_count = float(overview.get("down_count") or 0) + breadth = up_count / max(up_count + down_count, 1) + breadth_score = _clamp((breadth - 0.5) * 2, -1, 1) + current_amount = float(overview.get("amount_billion") or 0) + history_amounts = [float(item.get("amount_billion") or 0) for item in recent_history[:-1] if item.get("amount_billion")] + average_amount = ( + float(overview.get("recent_average_amount_billion") or 0) + if "recent_average_amount_billion" in overview + else sum(history_amounts) / len(history_amounts) if history_amounts else current_amount + ) + amount_change = _clamp((current_amount / max(average_amount, 1) - 1) * 3, -1, 1) + limit_up = float(overview.get("limit_up_count") or 0) + limit_down = float(overview.get("limit_down_count") or 0) + limit_balance = _clamp((limit_up - limit_down) / max(limit_up + limit_down, 1), -1, 1) + market_inner = _clamp( + (sentiment * 2 - 1) * 0.35 + + (seal_rate * 2 - 1) * 0.2 + + amount_change * 0.2 + + breadth_score * 0.15 + + limit_balance * 0.1, + -1, + 1, + ) + + aggregate = index_context.get("aggregate") or {} + if aggregate: + index_change = _clamp(float(aggregate.get("average_pct_chg") or 0) / 3, -1, 1) + market_outer = index_change + index_evidence = [ + f"主要指数平均涨跌 {float(aggregate.get('average_pct_chg') or 0):+.2f}%", + f"主要指数5日平均 {float(aggregate.get('average_return_5d') or 0):+.2f}%(趋势旁证,不参与外显阴阳)", + ] + else: + market_outer = _clamp(breadth_score * 0.65 + limit_balance * 0.35, -1, 1) + index_evidence = ["指数接口不可用,以市场宽度和涨跌停结构代替"] + return [ + { + "score": stock_inner, + "evidence": [ + f"成交额 {stock_amount:.2f} 亿,全市场分位 {amount_rank * 100:.0f}%", + ( + f"换手 {float(stock.get('turnover_rate') or 0):.2f}% / 市场 {float(stock.get('market_turnover_rate') or 0):.2f}%;" + f"同进度量能 {float(stock.get('volume_activity_ratio') or 0):.2f} 倍" + if stock_intraday + else f"换手率 {float(stock.get('turnover_rate') or 0):.2f}%,开板 {int(stock.get('open_times') or 0)} 次" + ), + ], + }, + { + "score": stock_outer, + "evidence": [ + f"{stock.get('name') or '--'}涨跌 {float(stock.get('change') or 0):+.2f}%", + f"状态 {stock.get('status') or '普通'},连板 {int(stock.get('streak') or 0)}", + ], + }, + { + "score": sector_inner, + "evidence": sector_inner_evidence, + }, + { + "score": sector_outer, + "evidence": sector_outer_evidence, + }, + { + "score": market_inner, + "evidence": [ + f"情绪得分 {float(overview.get('sentiment_score') or 0):.0f},封板率 {float(overview.get('seal_rate') or 0):.1f}%", + f"成交额较近期均值 {amount_change / 3 * 100:+.1f}%,涨跌停 {int(limit_up)}:{int(limit_down)}", + ], + }, + { + "score": market_outer, + "evidence": index_evidence + [f"上涨 {int(up_count)} 家,下跌 {int(down_count)} 家"], + }, + ] + + +def _score_to_line(score: float) -> int: + if score >= 0.72: + return 9 + if score >= 0: + return 7 + if score <= -0.72: + return 6 + return 8 + + +def _momentum_label(score: float) -> str: + if score >= 0.45: + return "势盛而动" + if score >= 0.12: + return "势起未极" + if score > -0.12: + return "阴阳相持" + if score > -0.45: + return "势弱宜察" + return "势衰宜守" + + +def _current_qi_step(lunar: Any, ymd: str) -> int: + """按六气分步边界返回当前步次。 + + 本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换; + 大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。 + """ + current = int(ymd.replace("-", "")) + table = lunar.getJieQiTable() + boundaries = [] + for name in ("大寒", "春分", "小满", "大暑", "秋分", "小雪"): + solar = table.get(name) + if solar is None: + continue + boundaries.append(int(solar.toYmd().replace("-", ""))) + if len(boundaries) != 6: + return 1 + if current < boundaries[0] or current >= boundaries[5]: + return 6 + for index in range(5): + if boundaries[index] <= current < boundaries[index + 1]: + return index + 1 + return 6 + + +def _guest_host_relation(host_qi: str, guest_qi: str) -> dict[str, str]: + """按客气加临主气的五行生克关系给出确定性判定。""" + host_element = QI_ELEMENT[host_qi] + guest_element = QI_ELEMENT[guest_qi] + if guest_element == host_element: + relation = { + "type": "same", + "label": "客主同气", + "order": "同气", + "tendency": "同类之气相并,得势则显,偏盛则亢", + } + elif ELEMENT_GENERATES[guest_element] == host_element: + relation = { + "type": "guest_generates_host", + "label": "客生主", + "order": "相得", + "tendency": "客气生助主气,气机较易相接", + } + elif ELEMENT_GENERATES[host_element] == guest_element: + relation = { + "type": "host_generates_guest", + "label": "主生客", + "order": "相生有泄", + "tendency": "主气生客,时令之力向外流转", + } + elif ELEMENT_CONTROLS[guest_element] == host_element: + relation = { + "type": "guest_controls_host", + "label": "客克主", + "order": "客胜为从", + "tendency": "客气制主,外来变化居于上风", + } + else: + relation = { + "type": "host_controls_guest", + "label": "主克客", + "order": "主胜为逆", + "tendency": "主气制客,时令与来气相持", + } + return { + **relation, + "host_qi": host_qi, + "host_element": host_element, + "guest_qi": guest_qi, + "guest_element": guest_element, + "basis": f"客{guest_element}加临主{host_element}", + } + + +def _annual_qi_pattern( + movement_element: str, + sitian_element: str, + year_branch: str, +) -> dict[str, Any]: + """判定中运与岁气的天符、岁会及太乙天符核心格局。""" + is_tianfu = movement_element == sitian_element + is_suihui = ( + year_branch in SUIHUI_BRANCHES + and movement_element == BRANCH_ELEMENT[year_branch] + ) + names = [] + if is_tianfu: + names.append("天符") + if is_suihui: + names.append("岁会") + primary = "太乙天符" if is_tianfu and is_suihui else (names[0] if names else "") + if primary == "太乙天符": + summary = "中运、司天与岁支同气,岁气相合尤著。" + elif primary == "天符": + summary = "中运与司天同气,运气相合。" + elif primary == "岁会": + summary = "中运与岁支五行同气,岁运相会。" + else: + summary = "中运、司天与岁支各循其位。" + return { + "primary": primary, + "names": names, + "is_tianfu": is_tianfu, + "is_suihui": is_suihui, + "summary": summary, + } + + +def _sector_phase_catalog(overrides: dict[str, str] | None = None) -> list[dict[str, Any]]: + """返回完整五行行业词表;精确手动归类可移动或新增词条。""" + manual = { + str(name).strip(): element + for name, element in (overrides or {}).items() + if str(name).strip() and element in PHASE_INFO + } + grouped: dict[str, list[dict[str, str]]] = {element: [] for element in PHASE_INFO} + seen: set[str] = set() + for default_element, keywords in SECTOR_PHASE_RULES.items(): + for keyword in keywords: + if keyword in seen: + continue + seen.add(keyword) + target = manual.get(keyword, default_element) + grouped[target].append( + { + "name": keyword, + "classification_source": "manual" if keyword in manual else "builtin", + } + ) + for name, element in manual.items(): + if name in seen: + continue + seen.add(name) + grouped[element].append({"name": name, "classification_source": "manual"}) + return [ + { + "element": element, + "count": len(grouped[element]), + "industries": grouped[element], + } + for element in PHASE_INFO + ] + + +def _sector_element(name: str, overrides: dict[str, str] | None = None) -> str: + normalized_name = name.strip() + manual_element = (overrides or {}).get(normalized_name) + if manual_element in PHASE_INFO: + return manual_element + best_element = "土" + best_keyword_length = 0 + for element, keywords in SECTOR_PHASE_RULES.items(): + for keyword in keywords: + if keyword in normalized_name and len(keyword) > best_keyword_length: + best_element = element + best_keyword_length = len(keyword) + return best_element + + +def _add_phase(weights: dict[str, float], element: str, amount: float) -> None: + weights[element] = weights.get(element, 0) + amount + + +def _dominant_phase(weights: dict[str, float]) -> str: + return max(weights.items(), key=lambda item: item[1])[0] + + +def _movement_label(stem: str) -> str: + phase = STEM_MOVEMENT[stem] + tendency = "太过" if stem in YANG_STEMS else "不及" + return f"{MOVEMENT_PAIR[phase]},{phase}运{tendency}" + + +def _phase_distribution(weights: dict[str, float]) -> list[dict[str, Any]]: + total = sum(weights.values()) or 1 + return [ + {"element": element, "score": score, "percent": round(score / total * 100)} + for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True) + if score > 0 + ] + + +def _percentile(value: float, values: list[float]) -> float: + clean = sorted(item for item in values if math.isfinite(item)) + if not clean: + return 0.5 + return sum(item <= value for item in clean) / len(clean) + + +def _clamp(value: float, minimum: float = 0, maximum: float = 1) -> float: + return max(minimum, min(maximum, value)) + + +@lru_cache(maxsize=1) +def _iching_data() -> dict[str, Any]: + payload = json.loads(ICHING_DATA_FILE.read_text(encoding="utf-8")) + data = payload.get("hexagrams") or {} + if len(data) != 64: + raise ValueError("六十四卦经典数据不完整。") + return data diff --git a/app/backend/features/heaven/http.py b/app/backend/features/heaven/http.py new file mode 100644 index 0000000..0defe12 --- /dev/null +++ b/app/backend/features/heaven/http.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import json +from http import HTTPStatus + + +class HeavenHttpMixin: + def heaven_hexagram(self) -> None: + try: + body = self.read_json_body() + result = self.application_service.heaven_hexagram(body.get("lines")) + self.send_json({"ok": True, "hexagram": result}) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def heaven_personal(self) -> None: + try: + body = self.read_json_body() + result = self.application_service.heaven_personal(body) + self.send_json({"ok": True, "personal": result}) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def heaven_interpret(self) -> None: + try: + body = self.read_json_body() + result = self.application_service.heaven_interpret(body) + self.send_json({"ok": True, **result}) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) diff --git a/app/backend/features/heaven/repository.py b/app/backend/features/heaven/repository.py new file mode 100644 index 0000000..0dc8115 --- /dev/null +++ b/app/backend/features/heaven/repository.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any + + +class HeavenRepositoryMixin: + @staticmethod + def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if not row: + return None + return { + "id": int(row["id"]), + "mode": str(row["mode"]), + "context_date": str(row["context_date"]), + "subject": str(row["subject"]), + "subject_detail": str(row["subject_detail"]), + "answer": str(row["answer"]), + "created_at": str(row["created_at"]), + } + + def save_heaven_reading( + self, + user_id: int, + mode: str, + context_date: str, + subject: str, + subject_detail: str, + answer: str, + context_snapshot: dict[str, Any], + dedupe_key: str, + ) -> dict[str, Any]: + now = datetime.now().astimezone().isoformat(timespec="seconds") + snapshot_json = json.dumps( + context_snapshot, ensure_ascii=False, separators=(",", ":") + ) + with self.connect() as connection: + connection.execute( + """ + INSERT INTO heaven_readings + (user_id, mode, context_date, subject, subject_detail, answer, + context_snapshot, dedupe_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, dedupe_key) DO NOTHING + """, + ( + int(user_id), mode, context_date, subject, subject_detail, + answer, snapshot_json, dedupe_key, now, + ), + ) + row = connection.execute( + """ + SELECT id, mode, context_date, subject, subject_detail, answer, created_at + FROM heaven_readings WHERE user_id = ? AND dedupe_key = ? + """, + (int(user_id), dedupe_key), + ).fetchone() + connection.execute( + """ + DELETE FROM heaven_readings + WHERE user_id = ? AND mode = ? AND id NOT IN ( + SELECT id FROM heaven_readings + WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100 + ) + """, + (int(user_id), mode, int(user_id), mode), + ) + result = self._heaven_reading_dict(row) + if not result: + raise ValueError("解读记录保存失败。") + return result + + def list_heaven_readings( + self, + user_id: int, + mode: str, + context_date: str = "", + limit: int = 100, + ) -> list[dict[str, Any]]: + clauses = ["user_id = ?", "mode = ?"] + parameters: list[Any] = [int(user_id), mode] + if context_date: + clauses.append("context_date = ?") + parameters.append(context_date) + parameters.append(max(1, min(100, int(limit)))) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT id, mode, context_date, subject, subject_detail, answer, created_at + FROM heaven_readings WHERE {' AND '.join(clauses)} + ORDER BY context_date DESC, id DESC LIMIT ? + """, + parameters, + ).fetchall() + return [self._heaven_reading_dict(row) for row in rows if row] + + def latest_heaven_reading( + self, user_id: int, mode: str, context_date: str = "" + ) -> dict[str, Any] | None: + items = self.list_heaven_readings(user_id, mode, context_date, 1) + return items[0] if items else None + + def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM heaven_readings WHERE id = ? AND user_id = ?", + (int(reading_id), int(user_id)), + ) + return cursor.rowcount > 0 diff --git a/app/backend/features/heaven/service.py b/app/backend/features/heaven/service.py new file mode 100644 index 0000000..86f5936 --- /dev/null +++ b/app/backend/features/heaven/service.py @@ -0,0 +1,1303 @@ +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 diff --git a/app/database.py b/app/database.py index d55cc9a..2f75274 100644 --- a/app/database.py +++ b/app/database.py @@ -10,6 +10,7 @@ from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactor from backend.features.accounts.repository import AccountRepositoryMixin from backend.features.auction.repository import AuctionRepositoryMixin from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin +from backend.features.heaven.repository import HeavenRepositoryMixin from backend.features.market.repository import MarketRepositoryMixin from backend.features.mentor.repository import MentorRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin @@ -23,6 +24,7 @@ class ReviewDatabase( AccountRepositoryMixin, AuctionRepositoryMixin, DragonTigerRepositoryMixin, + HeavenRepositoryMixin, MarketRepositoryMixin, MentorRepositoryMixin, PoolRepositoryMixin, @@ -1121,106 +1123,3 @@ class ReviewDatabase( "DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),) ) return int(cursor.rowcount) - - @staticmethod - def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: - if not row: - return None - return { - "id": int(row["id"]), - "mode": str(row["mode"]), - "context_date": str(row["context_date"]), - "subject": str(row["subject"]), - "subject_detail": str(row["subject_detail"]), - "answer": str(row["answer"]), - "created_at": str(row["created_at"]), - } - - def save_heaven_reading( - self, - user_id: int, - mode: str, - context_date: str, - subject: str, - subject_detail: str, - answer: str, - context_snapshot: dict[str, Any], - dedupe_key: str, - ) -> dict[str, Any]: - now = datetime.now().astimezone().isoformat(timespec="seconds") - snapshot_json = json.dumps( - context_snapshot, ensure_ascii=False, separators=(",", ":") - ) - with self.connect() as connection: - connection.execute( - """ - INSERT INTO heaven_readings - (user_id, mode, context_date, subject, subject_detail, answer, - context_snapshot, dedupe_key, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, dedupe_key) DO NOTHING - """, - ( - int(user_id), mode, context_date, subject, subject_detail, - answer, snapshot_json, dedupe_key, now, - ), - ) - row = connection.execute( - """ - SELECT id, mode, context_date, subject, subject_detail, answer, created_at - FROM heaven_readings WHERE user_id = ? AND dedupe_key = ? - """, - (int(user_id), dedupe_key), - ).fetchone() - connection.execute( - """ - DELETE FROM heaven_readings - WHERE user_id = ? AND mode = ? AND id NOT IN ( - SELECT id FROM heaven_readings - WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100 - ) - """, - (int(user_id), mode, int(user_id), mode), - ) - result = self._heaven_reading_dict(row) - if not result: - raise ValueError("解读记录保存失败。") - return result - - def list_heaven_readings( - self, - user_id: int, - mode: str, - context_date: str = "", - limit: int = 100, - ) -> list[dict[str, Any]]: - clauses = ["user_id = ?", "mode = ?"] - parameters: list[Any] = [int(user_id), mode] - if context_date: - clauses.append("context_date = ?") - parameters.append(context_date) - parameters.append(max(1, min(100, int(limit)))) - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT id, mode, context_date, subject, subject_detail, answer, created_at - FROM heaven_readings WHERE {' AND '.join(clauses)} - ORDER BY context_date DESC, id DESC LIMIT ? - """, - parameters, - ).fetchall() - return [self._heaven_reading_dict(row) for row in rows if row] - - def latest_heaven_reading( - self, user_id: int, mode: str, context_date: str = "" - ) -> dict[str, Any] | None: - items = self.list_heaven_readings(user_id, mode, context_date, 1) - return items[0] if items else None - - def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM heaven_readings WHERE id = ? AND user_id = ?", - (int(reading_id), int(user_id)), - ) - return cursor.rowcount > 0 diff --git a/app/heaven_agent.py b/app/heaven_agent.py index e4d5fcc..ebef605 100644 --- a/app/heaven_agent.py +++ b/app/heaven_agent.py @@ -1,118 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical heaven agent implementation.""" -import json -import time -import urllib.error -import urllib.request -from typing import Any +import sys +from backend.features.heaven import agent as _implementation -class HeavenAgentError(RuntimeError): - pass - - -def interpret_heaven( - mode: str, - context: dict[str, Any], - api_key: str, - base_url: str, - model: str, - timeout: int = 90, -) -> dict[str, Any]: - if mode not in {"trend", "fortune", "heart"}: - raise HeavenAgentError("不支持的问天解读模式。") - if not api_key or not model: - raise HeavenAgentError("LLM API Key 或模型尚未配置。") - system_prompt = _system_prompt(mode) - payload = json.dumps( - { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": json.dumps(context, ensure_ascii=False, separators=(",", ":")), - }, - ], - "stream": False, - }, - ensure_ascii=False, - ).encode("utf-8") - request = urllib.request.Request( - f"{base_url.rstrip('/')}/chat/completions", - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": "XiaobaiReviewWeb/0.7", - }, - method="POST", - ) - started = time.perf_counter() - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - result = json.loads(response.read().decode("utf-8")) - answer = str(result["choices"][0]["message"]["content"]).strip() - if not answer: - raise KeyError("empty response") - except urllib.error.HTTPError as exc: - raise HeavenAgentError(_http_error_message(exc)) from exc - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: - raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc - return { - "answer": answer, - "model": model, - "latency_ms": round((time.perf_counter() - started) * 1000), - } - - -def _system_prompt(mode: str) -> str: - common = """ -你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。 -问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。 -使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。 -""".strip() - if mode == "trend": - return common + """ - -当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。 -行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。 -先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。 -重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。 -全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。 -不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。 -""".strip() - if mode == "fortune": - return common + """ - -当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。 -严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。 -重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。 -首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。 -如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。 -不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。 -全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。 -""".strip() - return common + """ - -当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。 -全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。 -不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。 -""".strip() - - -def _http_error_message(exc: urllib.error.HTTPError) -> str: - detail = "" - try: - payload = json.loads(exc.read().decode("utf-8", errors="replace")) - error = payload.get("error") - if isinstance(error, dict): - detail = str(error.get("message") or error.get("code") or "") - elif error: - detail = str(error) - elif payload.get("message"): - detail = str(payload["message"]) - except (json.JSONDecodeError, OSError): - detail = "" - suffix = f":{detail[:300]}" if detail else "" - return f"问天模型调用失败(HTTP {exc.code}){suffix}" +sys.modules[__name__] = _implementation diff --git a/app/heaven_engine.py b/app/heaven_engine.py index 9545d47..66b9f30 100644 --- a/app/heaven_engine.py +++ b/app/heaven_engine.py @@ -1,1182 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical heaven engine implementation.""" -import json -import math import sys -from datetime import datetime -from functools import lru_cache -from pathlib import Path -from typing import Any +from backend.features.heaven import engine as _implementation -APP_DIR = Path(__file__).resolve().parent -VENDOR_DIR = APP_DIR / "vendor" -ICHING_DATA_FILE = APP_DIR / "data" / "iching_zh.json" -if str(VENDOR_DIR) not in sys.path: - sys.path.insert(0, str(VENDOR_DIR)) - -from lunar_python import Solar # noqa: E402 -from lunar_python.util import LunarUtil # noqa: E402 - - -TRIGRAM_NAMES = { - (1, 1, 1): "乾", - (1, 1, 0): "兑", - (1, 0, 1): "离", - (1, 0, 0): "震", - (0, 1, 1): "巽", - (0, 1, 0): "坎", - (0, 0, 1): "艮", - (0, 0, 0): "坤", -} - -LINE_POSITIONS = ("初爻", "二爻", "三爻", "四爻", "五爻", "上爻") -LINE_ROLES = ( - ("地", "内", "个股内核"), - ("地", "外", "个股外显"), - ("人", "内", "行业内核"), - ("人", "外", "行业外显"), - ("天", "内", "指数内核"), - ("天", "外", "指数外显"), -) - -STEM_MOVEMENT = { - "甲": "土", "己": "土", - "乙": "金", "庚": "金", - "丙": "水", "辛": "水", - "丁": "木", "壬": "木", - "戊": "火", "癸": "火", -} -MOVEMENT_PAIR = { - "土": "甲己化土", - "金": "乙庚化金", - "水": "丙辛化水", - "木": "丁壬化木", - "火": "戊癸化火", -} -YANG_STEMS = set("甲丙戊庚壬") -STEM_ELEMENT = { - "甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", - "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水", -} -BRANCH_ELEMENT = { - "子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", "巳": "火", - "午": "火", "未": "土", "申": "金", "酉": "金", "戌": "土", "亥": "水", -} -SUIHUI_BRANCHES = set("子丑卯辰午未酉戌") -SITIAN = { - "子": "少阴君火", "午": "少阴君火", - "丑": "太阴湿土", "未": "太阴湿土", - "寅": "少阳相火", "申": "少阳相火", - "卯": "阳明燥金", "酉": "阳明燥金", - "辰": "太阳寒水", "戌": "太阳寒水", - "巳": "厥阴风木", "亥": "厥阴风木", -} -ZAIQUAN = { - "少阴君火": "阳明燥金", - "太阴湿土": "太阳寒水", - "少阳相火": "厥阴风木", - "阳明燥金": "少阴君火", - "太阳寒水": "太阴湿土", - "厥阴风木": "少阳相火", -} -# 客气次序(一阴→二阴→三阴→一阳→二阳→三阳)。 -QI_SEQUENCE = ("厥阴风木", "少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水") -# 主气次序(固定,按五行相生:木→君火→相火→湿土→燥金→寒水)。 -HOST_QI_SEQUENCE = ("厥阴风木", "少阴君火", "少阳相火", "太阴湿土", "阳明燥金", "太阳寒水") -QI_ELEMENT = { - "厥阴风木": "木", "少阴君火": "火", "太阴湿土": "土", - "少阳相火": "火", "阳明燥金": "金", "太阳寒水": "水", -} -STEP_NAMES = ("初之气", "二之气", "三之气", "四之气", "五之气", "终之气") -PHASE_INFO = { - "木": {"motion": "生发、扩散、延展", "mind": "更愿意尝试新方向,也容易高估成长斜率"}, - "火": {"motion": "显化、加速、躁动", "mind": "注意力集中、追逐速度,也容易冲动和过度一致"}, - "土": {"motion": "承载、黏合、迟滞", "mind": "偏好确定和稳定,也可能出现犹豫与路径依赖"}, - "金": {"motion": "收敛、裁决、肃降", "mind": "纪律和风险意识增强,也容易形成快速杀估值"}, - "水": {"motion": "流动、潜藏、下行", "mind": "资金更重视流动性和退路,也可能放大恐惧传染"}, -} -PHASE_BEHAVIOR = { - "木": { - "emotion": "求新与扩张感增强,容易对新题材迅速产生期待", - "bias": "倾向先看到成长空间,再补风险验证", - "operation": "更想试仓、开新方向或给趋势更高估值", - "risk": "防止把萌芽当成主升,把想象力当成确认", - "balance": "先写清验证条件,等分歧后的承接再决定是否加码", - }, - "火": { - "emotion": "兴奋、急迫和表现欲更容易被放大,群体注意力趋于集中", - "bias": "倾向追逐速度与一致性,低估高位拥挤和冲动成本", - "operation": "更容易追涨、抢先手、放宽原有仓位上限", - "risk": "防止情绪高潮时把一致误作确定,把速度误作安全", - "balance": "延迟一次下单冲动,用成交承接和次日反馈替代情绪确认", - }, - "土": { - "emotion": "对确定性和安全感的需求上升,也容易迟疑、黏滞", - "bias": "倾向依赖熟悉路径,对已经持有的判断更难松手", - "operation": "更容易守仓、等确认,或因不愿认错而延迟处理", - "risk": "防止把稳定感当作低风险,把犹豫当作耐心", - "balance": "把持仓理由量化,触发失效条件时按计划减法处理", - }, - "金": { - "emotion": "警觉、挑剔和裁决感增强,容错意愿下降", - "bias": "倾向快速分辨强弱,也可能过早否定尚在修复的机会", - "operation": "更容易止损、兑现、收缩仓位并集中到辨识度高的标的", - "risk": "防止在恐慌扩散时机械割裂,也防止过度追求完美买点", - "balance": "区分逻辑失效与价格波动,给修复保留一个观察窗口", - }, - "水": { - "emotion": "不确定感与避险意识上升,消息和恐惧更容易传染", - "bias": "倾向先寻找退路,可能放大流动性风险或反复试探", - "operation": "更容易降仓、观望、快进快出,偏好有流动性的方向", - "risk": "防止因想象最坏结果而在低流动性时点失去判断", - "balance": "降低频率,保留现金与预案,只处理能清楚定义风险的交易", - }, -} -ELEMENT_GENERATES = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} -ELEMENT_CONTROLS = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"} -SECTOR_PHASE_RULES = { - "木": ( - # 植物生长类 + 仁术(医) + 教化(教育) + 纤维文书 - "农业", "种植", "种业", "林业", "园林", "畜牧", "养殖", "饲料", - "医药", "中药", "生物医药", "创新药", "医疗", "疫苗", - "教育", "培训", "出版", "图书", - "纺织", "服装", "服饰", "家纺", "造纸", "印刷", "包装", - "家具", "家居", "木材", "烟草", - ), - "火": ( - # 光热能源 + 电子传媒 + 炉灶 - "电力", "火电", "光伏", "太阳能", "风电", "储能", "电池", "锂电", - "充电桩", "新能源", "核电", "煤炭", "石油", "石化", "燃气", - "电子", "半导体", "芯片", "集成电路", "消费电子", "光学", "光电", - "显示", "面板", "通信", "计算机", "软件", "互联网", "游戏", - "人工智能", "数据", "云计算", "传媒", "影视", "广告", "娱乐", "直播", - ), - "土": ( - # 不动产 + 营造 + 稼穑饮食(土主养育) - "地产", "房地产", "物业", "建筑", "基建", "工程", "路桥", - "建材", "水泥", "玻璃", "陶瓷", "混凝土", "管材", "防水", - "食品", "乳业", "肉制品", "调味品", "农产品加工", - "零售", "百货", "仓储", - ), - "金": ( - # 金属机械 + 财帛裁决 + 兵戈肃杀 - "银行", "证券", "保险", "期货", "信托", "金融", "支付", - "钢铁", "有色", "金属", "贵金属", "黄金", "稀土", - "机械", "设备", "机床", "机器人", "仪器", "仪表", - "汽车", "整车", "零部件", "家电", "五金", - "军工", "国防", "兵器", "船舶", "航天", - ), - "水": ( - # 流动运输 + 液体 + 商旅(水主流、主智) - "航运", "港口", "物流", "快递", "运输", "航空", "机场", - "水务", "供水", "污水", "水利", "环保", - "饮料", "白酒", "啤酒", "黄酒", - "化工", "化学", "化纤", - "旅游", "酒店", "餐饮", "水产", "渔业", "贸易", "商贸", - ), -} - - -def build_market_hexagram( - dashboard: dict[str, Any], - recent_history: list[dict[str, Any]], - index_context: dict[str, Any] | None = None, - sector_name: str = "", - stock_code: str = "", - external_stock: dict[str, Any] | None = None, - external_sector: dict[str, Any] | None = None, -) -> dict[str, Any]: - sectors = list(dashboard.get("sectors") or []) - limits = list(dashboard.get("limits") or []) - broken = list(dashboard.get("broken") or []) - down_limits = list(dashboard.get("down_limits") or []) - normalized_sector = sector_name.strip().lower() - external_sector = external_sector or {} - selected_sector = next( - ( - item for item in sectors - if str(item.get("name") or "").strip().lower() == normalized_sector - or (normalized_sector and normalized_sector in str(item.get("name") or "").strip().lower()) - ), - None, - ) - if external_sector: - selected_sector = external_sector - external_stock = external_stock or {} - external_stock_sector = str(external_stock.get("sector") or "").strip() - if selected_sector is None and external_stock_sector: - selected_sector = next((item for item in sectors if item.get("name") == external_stock_sector), None) - if selected_sector is None and external_stock: - selected_sector = { - "name": external_stock_sector or sector_name.strip() or "个股所属行业", - "leader": external_stock.get("name") or "--", - "change": external_stock.get("change") or 0, - "strength": max(0, min(100, 50 + float(external_stock.get("change") or 0) * 3)), - "amount_billion": external_stock.get("amount_billion") or 0, - "count": 0, - "max_streak": 0, - } - selected_sector = selected_sector or (sectors[0] if sectors else {}) - actual_sector = str(selected_sector.get("name") or "暂无热点") - sector_stocks = [row for row in limits + broken + down_limits if row.get("sector") == actual_sector] - selected_stock = next((row for row in sector_stocks if str(row.get("code")) == stock_code), None) - if selected_stock is None and external_stock: - selected_stock = external_stock - if selected_stock is None and selected_sector.get("leader"): - selected_stock = next( - (row for row in sector_stocks if row.get("name") == selected_sector.get("leader")), - None, - ) - selected_stock = selected_stock or (sector_stocks[0] if sector_stocks else (limits[0] if limits else {})) - - scores = _market_line_scores( - dashboard, - recent_history, - index_context or {}, - selected_sector, - selected_stock, - limits, - ) - values = [_score_to_line(item["score"]) for item in scores] - hexagram = hexagram_from_lines(values) - for index, (line, score) in enumerate(zip(hexagram["lines"], scores)): - talent, layer, role = LINE_ROLES[index] - line.update( - { - "talent": talent, - "layer": layer, - "role": role, - "score": round(score["score"], 3), - "evidence": score["evidence"], - } - ) - pair_readings = [] - for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)): - inner = scores[inner_index]["score"] - outer = scores[outer_index]["score"] - if inner >= 0 and outer >= 0: - state = "内外相应,势有承载" - elif inner < 0 <= outer: - state = "外强内弱,表里有差" - elif inner >= 0 > outer: - state = "内强外抑,势待显化" - else: - state = "内外皆弱,宜守不宜躁" - pair_readings.append({"level": label, "state": state, "inner": round(inner, 3), "outer": round(outer, 3)}) - - options = [] - for sector in sectors[:20]: - name = str(sector.get("name") or "") - stocks = [row for row in limits + broken + down_limits if row.get("sector") == name] - options.append( - { - "name": name, - "leader": sector.get("leader") or "", - "stocks": [ - {"code": str(row.get("code") or ""), "name": row.get("name") or "--", "status": row.get("status") or ""} - for row in stocks[:20] - ], - } - ) - average_score = sum(item["score"] for item in scores) / 6 - moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]] - movement = { - "moving_lines": hexagram["moving_lines"], - "moving_names": moving_names, - "label": ( - f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}" - if moving_names - else f"无动爻,守{hexagram['name']}本势" - ), - "explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。", - } - return { - "data_trade_date": str(dashboard.get("meta", {}).get("trade_date") or ""), - "sector": actual_sector, - "sector_code": str(selected_sector.get("code") or ""), - "sector_taxonomy": str(selected_sector.get("taxonomy") or ""), - "stock": { - "code": str(selected_stock.get("code") or ""), - "name": selected_stock.get("name") or "--", - "status": selected_stock.get("status") or "", - }, - "selection_notice": "", - "hexagram": hexagram, - "movement": movement, - "pair_readings": pair_readings, - "momentum_score": round(average_score * 100), - "momentum_label": _momentum_label(average_score), - "sector_options": options, - "index_context": index_context or {}, - } - - -def build_manual_market_hexagram( - values: list[int], - data_trade_date: str, - sector: dict[str, Any] | None, - stock: dict[str, Any] | None, - index_context: dict[str, Any] | None = None, - note: str = "", -) -> dict[str, Any]: - """Build an explicitly user-calibrated chart without pretending it is market data.""" - hexagram = hexagram_from_lines(values) - score_map = {6: -0.85, 8: -0.35, 7: 0.35, 9: 0.85} - scores = [score_map[value] for value in values] - value_names = {6: "老阴·动", 8: "少阴·静", 7: "少阳·静", 9: "老阳·动"} - for index, line in enumerate(hexagram["lines"]): - talent, layer, role = LINE_ROLES[index] - line.update( - { - "talent": talent, - "layer": layer, - "role": role, - "score": scores[index], - "evidence": [f"用户手动校准为{value_names[values[index]]}"], - } - ) - - pair_readings = [] - for label, inner_index, outer_index in (("地·个股", 0, 1), ("人·行业", 2, 3), ("天·指数", 4, 5)): - inner, outer = scores[inner_index], scores[outer_index] - if inner >= 0 and outer >= 0: - state = "内外相应,势有承载" - elif inner < 0 <= outer: - state = "外强内弱,表里有差" - elif inner >= 0 > outer: - state = "内强外抑,势待显化" - else: - state = "内外皆弱,宜守不宜躁" - pair_readings.append({"level": label, "state": state, "inner": inner, "outer": outer}) - - moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]] - movement = { - "moving_lines": hexagram["moving_lines"], - "moving_names": moving_names, - "label": ( - f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}" - if moving_names else f"无动爻,守{hexagram['name']}本势" - ), - "explanation": "本卦看当下之势,动爻看势的转折处,之卦看变化所趋。", - } - average_score = sum(scores) / 6 - sector = sector or {} - stock = stock or {} - return { - "data_trade_date": data_trade_date, - "sector": str(sector.get("name") or stock.get("sector") or "--"), - "sector_code": str(sector.get("code") or ""), - "sector_taxonomy": str(sector.get("taxonomy") or ""), - "stock": { - "code": str(stock.get("code") or ""), - "name": str(stock.get("name") or "--"), - "status": str(stock.get("status") or ""), - }, - "selection_notice": "", - "hexagram": hexagram, - "movement": movement, - "pair_readings": pair_readings, - "momentum_score": round(average_score * 100), - "momentum_label": _momentum_label(average_score), - "sector_options": [], - "index_context": index_context or {}, - "manual_calibration": True, - "calibration_note": note, - } - - -def build_five_phase_field( - trade_date: str, - sector_phase_overrides: dict[str, str] | None = None, -) -> dict[str, Any]: - """构建指定日期的五运六气场。 - - 本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换; - 大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。 - """ - compact = trade_date.replace("-", "") - if len(compact) != 8 or not compact.isdigit(): - raise ValueError("日期格式应为 YYYY-MM-DD。") - year, month, day = int(compact[:4]), int(compact[4:6]), int(compact[6:]) - # 公共气场以日期为最小粒度。固定取正午只为构造历法对象,不引入时辰权重。 - solar = Solar.fromYmdHms(year, month, day, 12, 0, 0) - lunar = solar.getLunar() - year_gz = lunar.getYearInGanZhiExact() - month_gz = lunar.getMonthInGanZhiExact() - day_gz = lunar.getDayInGanZhiExact() - year_stem, year_branch = year_gz[0], year_gz[1] - movement_phase = STEM_MOVEMENT[year_stem] - movement_tendency = "太过" if year_stem in YANG_STEMS else "不及" - sitian = SITIAN[year_branch] - zaiquan = ZAIQUAN[sitian] - step = _current_qi_step(lunar, solar.toYmd()) - host_qi = HOST_QI_SEQUENCE[step - 1] - sitian_index = QI_SEQUENCE.index(sitian) - guest_qi = QI_SEQUENCE[(sitian_index - 2 + step - 1) % 6] - prev_jie_qi = lunar.getPrevJieQi() - next_jie_qi = lunar.getNextJieQi() - - # 年纲由中运与岁气共同建立。岁半以前司天为主,岁半以后在泉为主; - # 另一端仍保留背景作用,避免把天地升降误解为截然切断。 - sitian_weight, zaiquan_weight = (15, 5) if step <= 3 else (5, 15) - year_weights = {element: 0.0 for element in PHASE_INFO} - _add_phase(year_weights, movement_phase, 30) - _add_phase(year_weights, QI_ELEMENT[sitian], sitian_weight) - _add_phase(year_weights, QI_ELEMENT[zaiquan], zaiquan_weight) - - current_qi_weights = {element: 0.0 for element in PHASE_INFO} - _add_phase(current_qi_weights, QI_ELEMENT[host_qi], 20) - _add_phase(current_qi_weights, QI_ELEMENT[guest_qi], 25) - - day_weights = {element: 0.0 for element in PHASE_INFO} - _add_phase(day_weights, STEM_MOVEMENT[day_gz[0]], 2.5) - _add_phase(day_weights, BRANCH_ELEMENT[day_gz[1]], 2.5) - - weights = { - element: year_weights[element] + current_qi_weights[element] + day_weights[element] - for element in PHASE_INFO - } - total = sum(weights.values()) or 1 - balance = [ - { - "element": element, - "score": score, - "percent": round(score / total * 100), - **PHASE_INFO[element], - } - for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True) - ] - overrides = sector_phase_overrides or {} - sector_catalog = _sector_phase_catalog(overrides) - dominant = balance[0] - secondary = balance[1] - year_dominant = _dominant_phase(year_weights) - current_qi_dominant = _dominant_phase(current_qi_weights) - day_dominant = _dominant_phase(day_weights) - guest_host_relation = _guest_host_relation(host_qi, guest_qi) - annual_pattern = _annual_qi_pattern( - movement_phase, - QI_ELEMENT[sitian], - year_branch, - ) - annual_pattern_suffix = f";{annual_pattern['primary']}" if annual_pattern["primary"] else "" - ruling_qi = sitian if step <= 3 else zaiquan - ruling_label = "司天" if step <= 3 else "在泉" - alignment = "" - if guest_qi == sitian: - alignment = "司天同位" - elif guest_qi == zaiquan: - alignment = "在泉同位" - dominant_behavior = PHASE_BEHAVIOR[dominant["element"]] - secondary_behavior = PHASE_BEHAVIOR[secondary["element"]] - calendar_date = f"{year:04d}-{month:02d}-{day:02d}" - human_field = { - "summary": ( - f"年以{year_dominant}为纲,当前{STEP_NAMES[step - 1]}由{ruling_label}{ruling_qi}主其半岁," - f"客主呈{guest_host_relation['label']},日由{day_dominant}触发;" - f"合看以{dominant['element']}气偏显、{secondary['element']}气相随。{dominant_behavior['emotion']}。" - ), - "emotional_tendency": [dominant_behavior["emotion"], secondary_behavior["emotion"]], - "decision_biases": [dominant_behavior["bias"], secondary_behavior["bias"]], - "operation_tendency": dominant_behavior["operation"], - "risk_reminders": [dominant_behavior["risk"], secondary_behavior["risk"]], - "balancing_actions": [dominant_behavior["balance"], secondary_behavior["balance"]], - } - return { - "date": calendar_date, - "lunar_date": f"农历{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}", - "pillars": {"year": year_gz, "month": month_gz, "day": day_gz}, - "movement": { - "phase": movement_phase, - "tendency": movement_tendency, - "label": f"{movement_phase}运{movement_tendency}", - "basis": f"{year_stem}属{movement_phase}运,{year_stem}为{'阳干' if year_stem in YANG_STEMS else '阴干'}", - }, - "six_qi": { - "sitian": sitian, - "zaiquan": zaiquan, - "step": step, - "step_name": STEP_NAMES[step - 1], - "host_qi": host_qi, - "guest_qi": guest_qi, - "ruling": ruling_label, - "ruling_qi": ruling_qi, - "alignment": alignment, - }, - "solar_terms": { - "current": prev_jie_qi.getName(), - "current_at": prev_jie_qi.getSolar().toYmdHms(), - "next": next_jie_qi.getName(), - "next_at": next_jie_qi.getSolar().toYmdHms(), - }, - "framework": { - "principle": "先立年纲,再察客气加临主气;岁半以前司天为主,岁半以后在泉为主,日辰只作触发。六气自大寒步进,岁气以立春为界。", - "weights": { - "year_movement": 30, - "sitian_zaiquan": 20, - "sitian": sitian_weight, - "zaiquan": zaiquan_weight, - "host_qi": 20, - "guest_qi": 25, - "day": 5, - }, - "relations": { - "guest_host": guest_host_relation, - "annual_pattern": annual_pattern, - "alignment": alignment, - "ruling": { - "label": ruling_label, - "qi": ruling_qi, - "summary": f"当前由{ruling_label}{ruling_qi}主其半岁,另一端退居背景。", - }, - }, - "layers": [ - { - "id": "year", - "label": "年纲", - "weight": 50, - "dominant": year_dominant, - "summary": ( - f"{MOVEMENT_PAIR[movement_phase]},{movement_phase}运{movement_tendency};" - f"{ruling_label}{ruling_qi}当权" - f"{annual_pattern_suffix}" - ), - "balance": _phase_distribution(year_weights), - }, - { - "id": "current", - "label": "客主加临", - "weight": 45, - "dominant": current_qi_dominant, - "summary": ( - f"当前{STEP_NAMES[step - 1]},客{guest_qi}加临主{host_qi};" - f"{guest_host_relation['label']},{guest_host_relation['tendency']}" - ), - "balance": _phase_distribution(current_qi_weights), - }, - { - "id": "day", - "label": "日辰触发", - "weight": 5, - "dominant": day_dominant, - "summary": f"{day_gz}日,{_movement_label(day_gz[0])};{day_gz[1]}属{BRANCH_ELEMENT[day_gz[1]]}、应{SITIAN[day_gz[1]]}", - "balance": _phase_distribution(day_weights), - }, - ], - }, - "balance": balance, - "human_field": human_field, - "sector_catalog": sector_catalog, - "notice": "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。", - } - - -def build_personal_field( - birth_datetime: str, - gender: str, - current_date: str, - current_field: dict[str, Any] | None = None, -) -> dict[str, Any]: - try: - born = datetime.strptime(birth_datetime, "%Y-%m-%dT%H:%M") - except ValueError as exc: - raise ValueError("出生时间格式应为 YYYY-MM-DDTHH:MM。") from exc - if not 1900 <= born.year <= 2100: - raise ValueError("出生年份应在 1900 至 2100 年之间。") - if gender not in {"male", "female", "unspecified"}: - raise ValueError("性别选项不正确。") - - solar = Solar.fromYmdHms(born.year, born.month, born.day, born.hour, born.minute, 0) - lunar = solar.getLunar() - eight = lunar.getEightChar() - pillars = { - "year": eight.getYear(), - "month": eight.getMonth(), - "day": eight.getDay(), - "time": eight.getTime(), - } - visible_elements = {element: 0.0 for element in PHASE_INFO} - for key, pillar in pillars.items(): - visible_elements[STEM_ELEMENT[pillar[0]]] += 1 - visible_elements[BRANCH_ELEMENT[pillar[1]]] += 1.5 if key == "month" else 1 - total = sum(visible_elements.values()) or 1 - element_balance = [ - {"element": element, "score": round(score, 1), "percent": round(score / total * 100)} - for element, score in sorted(visible_elements.items(), key=lambda item: item[1], reverse=True) - ] - - day_master = eight.getDayGan() - day_element = STEM_ELEMENT[day_master] - resource_element = next(element for element, generated in ELEMENT_GENERATES.items() if generated == day_element) - output_element = ELEMENT_GENERATES[day_element] - wealth_element = ELEMENT_CONTROLS[day_element] - officer_element = next(element for element, controlled in ELEMENT_CONTROLS.items() if controlled == day_element) - support_score = visible_elements[day_element] + visible_elements[resource_element] - if support_score < total * 0.42: - strength = "偏弱" - favorable = [resource_element, day_element] - caution = [officer_element, wealth_element, output_element] - balance_note = "日主支持偏少,简化算法倾向先取生扶,再看泄耗与制约是否过强。" - elif support_score > total * 0.62: - strength = "偏强" - favorable = [output_element, wealth_element, officer_element] - caution = [day_element, resource_element] - balance_note = "日主支持偏多,简化算法倾向用泄、耗、制来恢复流动。" - else: - strength = "相对平衡" - favorable = [output_element, wealth_element] - caution = [element_balance[0]["element"]] - balance_note = "五行支持与消耗接近,简化算法更看重当下偏盛元素的调节。" - - ten_gods = { - "year": {"stem": eight.getYearShiShenGan(), "branches": eight.getYearShiShenZhi()}, - "month": {"stem": eight.getMonthShiShenGan(), "branches": eight.getMonthShiShenZhi()}, - "day": {"stem": "日主", "branches": eight.getDayShiShenZhi()}, - "time": {"stem": eight.getTimeShiShenGan(), "branches": eight.getTimeShiShenZhi()}, - } - ten_god_roles = { - day_element: "比劫", - resource_element: "印星", - output_element: "食伤", - wealth_element: "财星", - officer_element: "官杀", - } - - compact = current_date.replace("-", "") - if len(compact) != 8 or not compact.isdigit(): - raise ValueError("当前日期格式应为 YYYY-MM-DD。") - current_solar = Solar.fromYmdHms(int(compact[:4]), int(compact[4:6]), int(compact[6:]), 12, 0, 0) - current_lunar = current_solar.getLunar() - current_pillars = { - "year": current_lunar.getYearInGanZhiExact(), - "month": current_lunar.getMonthInGanZhiExact(), - "day": current_lunar.getDayInGanZhiExact(), - } - current_ten_gods = { - key: { - "pillar": pillar, - "stem": LunarUtil.SHI_SHEN.get(day_master + pillar[0]) or "--", - "branches": [LunarUtil.SHI_SHEN.get(day_master + gan) or "--" for gan in LunarUtil.ZHI_HIDE_GAN.get(pillar[1], [])], - } - for key, pillar in current_pillars.items() - } - field = current_field or build_five_phase_field(current_date) - dominant_elements = [item["element"] for item in field.get("balance", [])[:2]] - favorable_hits = [element for element in dominant_elements if element in favorable] - caution_hits = [element for element in dominant_elements if element in caution] - if favorable_hits and not caution_hits: - personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(favorable_hits)}较合你的平衡倾向,主观上更容易感到有支点。" - operation_note = "顺手感可能增强,但仍应把它当作自我状态提醒,不宜因此放宽交易纪律。" - elif caution_hits and not favorable_hits: - personal_tone = f"当日偏显的{'、'.join(dominant_elements)}中,{'、'.join(caution_hits)}可能放大你的耗泄或压力感。" - operation_note = "更适合降低决策频率,尤其留意急于证明、犹豫不决或过早止损等惯性反应。" - else: - personal_tone = f"当日{'、'.join(dominant_elements)}并见,对你既有助力也有牵制,感受可能随情境切换。" - operation_note = "先辨认自己此刻是兴奋、恐惧还是执着,再决定是否需要行动。" - return { - "birth": {"datetime": birth_datetime, "gender": gender, "lunar": lunar.toString()}, - "pillars": pillars, - "day_master": {"stem": day_master, "element": day_element, "strength": strength}, - "ten_gods": ten_gods, - "ten_god_tendency": { - "favorable": [ten_god_roles[element] for element in favorable], - "caution": [ten_god_roles[element] for element in caution], - }, - "element_balance": element_balance, - "balance_tendency": { - "favorable": favorable, - "caution": caution, - "note": balance_note, - "method": "按可见四柱五行、月令加权及日主生扶比例生成的简化平衡倾向,不等同于专业命理中的唯一喜用神结论。", - }, - "current": { - "date": current_date, - "pillars": current_pillars, - "ten_gods": current_ten_gods, - "tone": personal_tone, - "operation_note": operation_note, - }, - "notice": "个人结果仅供传统文化与自我观察使用。出生信息只在本机服务中计算。", - } - - -def hexagram_from_lines(values: list[int]) -> dict[str, Any]: - if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values): - raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。") - bits = tuple(1 if value % 2 else 0 for value in values) - transformed_values = [7 if value == 6 else 8 if value == 9 else value for value in values] - transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values) - data = _iching_data() - primary = data.get(str(bits)) - transformed = data.get(str(transformed_bits)) - if not primary or not transformed: - raise ValueError("卦象数据不完整。") - lines = [] - line_items = list(primary["lines"].values()) - for index, (value, item) in enumerate(zip(values, line_items)): - lines.append( - { - "position": index + 1, - "position_name": LINE_POSITIONS[index], - "value": value, - "yin_yang": "阳" if value % 2 else "阴", - "moving": value in {6, 9}, - "line_name": item["name"], - "text": item["text"], - "image": item.get("image") or "", - } - ) - inner = TRIGRAM_NAMES[bits[:3]] - outer = TRIGRAM_NAMES[bits[3:]] - transformed_inner = TRIGRAM_NAMES[transformed_bits[:3]] - transformed_outer = TRIGRAM_NAMES[transformed_bits[3:]] - return { - "name": primary["name"], - "text": primary["text"], - "image": primary.get("image") or "", - "inner_trigram": inner, - "outer_trigram": outer, - "lines": lines, - "moving_lines": [index + 1 for index, value in enumerate(values) if value in {6, 9}], - "transformed": { - "name": transformed["name"], - "text": transformed["text"], - "image": transformed.get("image") or "", - "inner_trigram": transformed_inner, - "outer_trigram": transformed_outer, - }, - } - - -def _market_line_scores( - dashboard: dict[str, Any], - recent_history: list[dict[str, Any]], - index_context: dict[str, Any], - sector: dict[str, Any], - stock: dict[str, Any], - limits: list[dict[str, Any]], -) -> list[dict[str, Any]]: - overview = dashboard.get("overview") or {} - stock_amount = float(stock.get("amount_billion") or 0) - stock_intraday = bool(stock.get("realtime")) or stock.get("_quantitative_mode") == "intraday" - if stock_intraday and stock.get("activity_source"): - amount_rank = _clamp(float(stock.get("amount_percentile") or 0) / 100) - turnover_relative = _clamp( - (float(stock.get("turnover_relative") or 0) - 1) / 1.5, - -1, - 1, - ) - volume_activity = _clamp( - (float(stock.get("volume_activity_ratio") or 0) - 1) / 1.5, - -1, - 1, - ) - stock_inner = _clamp( - (amount_rank * 2 - 1) * 0.35 - + turnover_relative * 0.35 - + volume_activity * 0.30, - -1, - 1, - ) - else: - amounts = [float(item.get("amount_billion") or 0) for item in limits] - amount_rank = ( - _clamp(float(stock.get("amount_percentile") or 0) / 100) - if "amount_percentile" in stock - else _percentile(stock_amount, amounts) - ) - turnover = _clamp(float(stock.get("turnover_rate") or 0) / 20) - seal = _clamp(float(stock.get("seal_amount_million") or 0) / 15000) - stability = 1 - _clamp(float(stock.get("open_times") or 0) / 6) - stock_inner_raw = 0.32 * amount_rank + 0.22 * turnover + 0.25 * seal + 0.21 * stability - stock_inner = stock_inner_raw * 2 - 1 - stock_change = _clamp(float(stock.get("change") or 0) / 10, -1, 1) - streak = _clamp(float(stock.get("streak") or 0) / 5) - status_adjustment = -0.7 if stock.get("status") == "跌停" else -0.25 if stock.get("status") == "炸板" else 0.15 - stock_outer = _clamp(stock_change * 0.7 + streak * 0.2 + status_adjustment, -1, 1) - - rotation = next( - (item for item in dashboard.get("sector_rotation") or [] if item.get("name") == sector.get("name")), - {}, - ) - sector_quantitative_mode = str(sector.get("_quantitative_mode") or "") - actual_sector_source = str(sector.get("source") or "").startswith("tushare_") - if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday": - sector_change = float(sector.get("change") or 0) - sector_change_score = _clamp(sector_change / 5, -1, 1) - sector_up = float(sector.get("up_count") or 0) - sector_down = float(sector.get("down_count") or 0) - sector_breadth = _clamp( - (sector_up - sector_down) / max(sector_up + sector_down, 1), -1, 1 - ) - relative_turnover_score = _clamp( - (float(sector.get("relative_turnover") or 0) - 1) / 1.5, - -1, - 1, - ) - leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1) - sector_inner = _clamp( - sector_breadth * 0.60 + relative_turnover_score * 0.40, - -1, - 1, - ) - sector_outer = _clamp( - sector_change_score * 0.90 + leading_score * 0.10, - -1, - 1, - ) - sector_inner_evidence = [ - f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家", - f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍", - ] - sector_outer_evidence = [ - f"申万二级行业官方涨跌 {sector_change:+.2f}%", - f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%", - ] - elif actual_sector_source or sector_quantitative_mode == "historical": - sector_change = float(sector.get("change") or 0) - sector_change_score = _clamp(sector_change / 5, -1, 1) - member_equal_change = float(sector.get("member_equal_change") if sector.get("member_equal_change") is not None else sector_change) - member_change_score = _clamp(member_equal_change / 5, -1, 1) - sector_up = float(sector.get("up_count") or 0) - sector_down = float(sector.get("down_count") or 0) - if sector_up + sector_down: - sector_breadth = _clamp((sector_up - sector_down) / (sector_up + sector_down), -1, 1) - else: - sector_breadth = sector_change_score - leading_score = _clamp(float(sector.get("leading_pct") or 0) / 10, -1, 1) - sector_inner = _clamp(sector_breadth * 0.6 + member_change_score * 0.35 + leading_score * 0.05, -1, 1) - sector_outer = _clamp(sector_change_score * 0.9 + leading_score * 0.1, -1, 1) - sector_inner_evidence = [ - f"行业上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家", - f"行业成分等权涨跌 {member_equal_change:+.2f}%", - ] - sector_outer_evidence = [ - f"{sector.get('name') or '--'}行业涨跌 {sector_change:+.2f}%", - f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%", - ] - else: - max_count = max([float(item.get("count") or 0) for item in dashboard.get("sectors") or []] or [1]) - sector_count = _clamp(float(sector.get("count") or 0) / max_count) - sector_strength = _clamp(float(sector.get("strength") or 0) / 100) - sector_amount = _clamp(float(sector.get("amount_billion") or 0) / 100) - delta = _clamp(float(rotation.get("delta") or 0) / 8, -1, 1) - sector_inner = _clamp((sector_count * 0.35 + sector_strength * 0.35 + sector_amount * 0.2 + (delta + 1) / 2 * 0.1) * 2 - 1) - leader_change = _clamp(float(sector.get("change") or 0) / 10, -1, 1) - max_streak = _clamp(float(sector.get("max_streak") or 0) / 5) - sector_outer = _clamp( - leader_change * 0.45 + sector_strength * 0.25 + max_streak * 0.2 + delta * 0.1, - -1, - 1, - ) - sector_inner_evidence = [ - f"{sector.get('name') or '--'}涨停 {int(sector.get('count') or 0)} 家,强度 {float(sector.get('strength') or 0):.0f}", - f"板块成交 {float(sector.get('amount_billion') or 0):.1f} 亿,家数变化 {float(rotation.get('delta') or 0):+.0f}", - ] - sector_outer_evidence = [ - f"领涨股 {sector.get('leader') or '--'},涨跌 {float(sector.get('change') or 0):+.2f}%", - f"最高 {int(sector.get('max_streak') or 0)} 板,轮动 {rotation.get('trend') or '暂无'}", - ] - - sentiment = _clamp(float(overview.get("sentiment_score") or 0) / 100) - seal_rate = _clamp(float(overview.get("seal_rate") or 0) / 100) - up_count = float(overview.get("up_count") or 0) - down_count = float(overview.get("down_count") or 0) - breadth = up_count / max(up_count + down_count, 1) - breadth_score = _clamp((breadth - 0.5) * 2, -1, 1) - current_amount = float(overview.get("amount_billion") or 0) - history_amounts = [float(item.get("amount_billion") or 0) for item in recent_history[:-1] if item.get("amount_billion")] - average_amount = ( - float(overview.get("recent_average_amount_billion") or 0) - if "recent_average_amount_billion" in overview - else sum(history_amounts) / len(history_amounts) if history_amounts else current_amount - ) - amount_change = _clamp((current_amount / max(average_amount, 1) - 1) * 3, -1, 1) - limit_up = float(overview.get("limit_up_count") or 0) - limit_down = float(overview.get("limit_down_count") or 0) - limit_balance = _clamp((limit_up - limit_down) / max(limit_up + limit_down, 1), -1, 1) - market_inner = _clamp( - (sentiment * 2 - 1) * 0.35 - + (seal_rate * 2 - 1) * 0.2 - + amount_change * 0.2 - + breadth_score * 0.15 - + limit_balance * 0.1, - -1, - 1, - ) - - aggregate = index_context.get("aggregate") or {} - if aggregate: - index_change = _clamp(float(aggregate.get("average_pct_chg") or 0) / 3, -1, 1) - market_outer = index_change - index_evidence = [ - f"主要指数平均涨跌 {float(aggregate.get('average_pct_chg') or 0):+.2f}%", - f"主要指数5日平均 {float(aggregate.get('average_return_5d') or 0):+.2f}%(趋势旁证,不参与外显阴阳)", - ] - else: - market_outer = _clamp(breadth_score * 0.65 + limit_balance * 0.35, -1, 1) - index_evidence = ["指数接口不可用,以市场宽度和涨跌停结构代替"] - return [ - { - "score": stock_inner, - "evidence": [ - f"成交额 {stock_amount:.2f} 亿,全市场分位 {amount_rank * 100:.0f}%", - ( - f"换手 {float(stock.get('turnover_rate') or 0):.2f}% / 市场 {float(stock.get('market_turnover_rate') or 0):.2f}%;" - f"同进度量能 {float(stock.get('volume_activity_ratio') or 0):.2f} 倍" - if stock_intraday - else f"换手率 {float(stock.get('turnover_rate') or 0):.2f}%,开板 {int(stock.get('open_times') or 0)} 次" - ), - ], - }, - { - "score": stock_outer, - "evidence": [ - f"{stock.get('name') or '--'}涨跌 {float(stock.get('change') or 0):+.2f}%", - f"状态 {stock.get('status') or '普通'},连板 {int(stock.get('streak') or 0)}", - ], - }, - { - "score": sector_inner, - "evidence": sector_inner_evidence, - }, - { - "score": sector_outer, - "evidence": sector_outer_evidence, - }, - { - "score": market_inner, - "evidence": [ - f"情绪得分 {float(overview.get('sentiment_score') or 0):.0f},封板率 {float(overview.get('seal_rate') or 0):.1f}%", - f"成交额较近期均值 {amount_change / 3 * 100:+.1f}%,涨跌停 {int(limit_up)}:{int(limit_down)}", - ], - }, - { - "score": market_outer, - "evidence": index_evidence + [f"上涨 {int(up_count)} 家,下跌 {int(down_count)} 家"], - }, - ] - - -def _score_to_line(score: float) -> int: - if score >= 0.72: - return 9 - if score >= 0: - return 7 - if score <= -0.72: - return 6 - return 8 - - -def _momentum_label(score: float) -> str: - if score >= 0.45: - return "势盛而动" - if score >= 0.12: - return "势起未极" - if score > -0.12: - return "阴阳相持" - if score > -0.45: - return "势弱宜察" - return "势衰宜守" - - -def _current_qi_step(lunar: Any, ymd: str) -> int: - """按六气分步边界返回当前步次。 - - 本系统约定:六气阶段以大寒为岁首步进,司天在泉随年干支以立春为界切换; - 大寒至立春之间,六气已入新一年初之气,司天在泉仍属旧年。 - """ - current = int(ymd.replace("-", "")) - table = lunar.getJieQiTable() - boundaries = [] - for name in ("大寒", "春分", "小满", "大暑", "秋分", "小雪"): - solar = table.get(name) - if solar is None: - continue - boundaries.append(int(solar.toYmd().replace("-", ""))) - if len(boundaries) != 6: - return 1 - if current < boundaries[0] or current >= boundaries[5]: - return 6 - for index in range(5): - if boundaries[index] <= current < boundaries[index + 1]: - return index + 1 - return 6 - - -def _guest_host_relation(host_qi: str, guest_qi: str) -> dict[str, str]: - """按客气加临主气的五行生克关系给出确定性判定。""" - host_element = QI_ELEMENT[host_qi] - guest_element = QI_ELEMENT[guest_qi] - if guest_element == host_element: - relation = { - "type": "same", - "label": "客主同气", - "order": "同气", - "tendency": "同类之气相并,得势则显,偏盛则亢", - } - elif ELEMENT_GENERATES[guest_element] == host_element: - relation = { - "type": "guest_generates_host", - "label": "客生主", - "order": "相得", - "tendency": "客气生助主气,气机较易相接", - } - elif ELEMENT_GENERATES[host_element] == guest_element: - relation = { - "type": "host_generates_guest", - "label": "主生客", - "order": "相生有泄", - "tendency": "主气生客,时令之力向外流转", - } - elif ELEMENT_CONTROLS[guest_element] == host_element: - relation = { - "type": "guest_controls_host", - "label": "客克主", - "order": "客胜为从", - "tendency": "客气制主,外来变化居于上风", - } - else: - relation = { - "type": "host_controls_guest", - "label": "主克客", - "order": "主胜为逆", - "tendency": "主气制客,时令与来气相持", - } - return { - **relation, - "host_qi": host_qi, - "host_element": host_element, - "guest_qi": guest_qi, - "guest_element": guest_element, - "basis": f"客{guest_element}加临主{host_element}", - } - - -def _annual_qi_pattern( - movement_element: str, - sitian_element: str, - year_branch: str, -) -> dict[str, Any]: - """判定中运与岁气的天符、岁会及太乙天符核心格局。""" - is_tianfu = movement_element == sitian_element - is_suihui = ( - year_branch in SUIHUI_BRANCHES - and movement_element == BRANCH_ELEMENT[year_branch] - ) - names = [] - if is_tianfu: - names.append("天符") - if is_suihui: - names.append("岁会") - primary = "太乙天符" if is_tianfu and is_suihui else (names[0] if names else "") - if primary == "太乙天符": - summary = "中运、司天与岁支同气,岁气相合尤著。" - elif primary == "天符": - summary = "中运与司天同气,运气相合。" - elif primary == "岁会": - summary = "中运与岁支五行同气,岁运相会。" - else: - summary = "中运、司天与岁支各循其位。" - return { - "primary": primary, - "names": names, - "is_tianfu": is_tianfu, - "is_suihui": is_suihui, - "summary": summary, - } - - -def _sector_phase_catalog(overrides: dict[str, str] | None = None) -> list[dict[str, Any]]: - """返回完整五行行业词表;精确手动归类可移动或新增词条。""" - manual = { - str(name).strip(): element - for name, element in (overrides or {}).items() - if str(name).strip() and element in PHASE_INFO - } - grouped: dict[str, list[dict[str, str]]] = {element: [] for element in PHASE_INFO} - seen: set[str] = set() - for default_element, keywords in SECTOR_PHASE_RULES.items(): - for keyword in keywords: - if keyword in seen: - continue - seen.add(keyword) - target = manual.get(keyword, default_element) - grouped[target].append( - { - "name": keyword, - "classification_source": "manual" if keyword in manual else "builtin", - } - ) - for name, element in manual.items(): - if name in seen: - continue - seen.add(name) - grouped[element].append({"name": name, "classification_source": "manual"}) - return [ - { - "element": element, - "count": len(grouped[element]), - "industries": grouped[element], - } - for element in PHASE_INFO - ] - - -def _sector_element(name: str, overrides: dict[str, str] | None = None) -> str: - normalized_name = name.strip() - manual_element = (overrides or {}).get(normalized_name) - if manual_element in PHASE_INFO: - return manual_element - best_element = "土" - best_keyword_length = 0 - for element, keywords in SECTOR_PHASE_RULES.items(): - for keyword in keywords: - if keyword in normalized_name and len(keyword) > best_keyword_length: - best_element = element - best_keyword_length = len(keyword) - return best_element - - -def _add_phase(weights: dict[str, float], element: str, amount: float) -> None: - weights[element] = weights.get(element, 0) + amount - - -def _dominant_phase(weights: dict[str, float]) -> str: - return max(weights.items(), key=lambda item: item[1])[0] - - -def _movement_label(stem: str) -> str: - phase = STEM_MOVEMENT[stem] - tendency = "太过" if stem in YANG_STEMS else "不及" - return f"{MOVEMENT_PAIR[phase]},{phase}运{tendency}" - - -def _phase_distribution(weights: dict[str, float]) -> list[dict[str, Any]]: - total = sum(weights.values()) or 1 - return [ - {"element": element, "score": score, "percent": round(score / total * 100)} - for element, score in sorted(weights.items(), key=lambda item: item[1], reverse=True) - if score > 0 - ] - - -def _percentile(value: float, values: list[float]) -> float: - clean = sorted(item for item in values if math.isfinite(item)) - if not clean: - return 0.5 - return sum(item <= value for item in clean) / len(clean) - - -def _clamp(value: float, minimum: float = 0, maximum: float = 1) -> float: - return max(minimum, min(maximum, value)) - - -@lru_cache(maxsize=1) -def _iching_data() -> dict[str, Any]: - payload = json.loads(ICHING_DATA_FILE.read_text(encoding="utf-8")) - data = payload.get("hexagrams") or {} - if len(data) != 64: - raise ValueError("六十四卦经典数据不完整。") - return data +sys.modules[__name__] = _implementation diff --git a/app/tests/test_market_mode.py b/app/tests/test_market_mode.py index 8e6af49..5fdd5a0 100644 --- a/app/tests/test_market_mode.py +++ b/app/tests/test_market_mode.py @@ -9,11 +9,11 @@ from tushare_client import _sector_coverage_issue def load_method(name: str): - source = Path("backend/application.py").read_text(encoding="utf-8") + source = Path("backend/features/heaven/service.py").read_text(encoding="utf-8") tree = ast.parse(source) dashboard_service = next( node for node in tree.body - if isinstance(node, ast.ClassDef) and node.name == "DashboardService" + if isinstance(node, ast.ClassDef) and node.name == "HeavenServiceMixin" ) method = next( node for node in dashboard_service.body diff --git a/app/tests/test_preservation_slice_heaven.py b/app/tests/test_preservation_slice_heaven.py new file mode 100644 index 0000000..8c94d0e --- /dev/null +++ b/app/tests/test_preservation_slice_heaven.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + +import heaven_agent +import heaven_engine +from backend.features.heaven import agent as canonical_agent +from backend.features.heaven import engine as canonical_engine + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +HEAVEN_SERVICE_METHODS = { + "_heaven_manual_schema", + "_validate_heaven_manual_data", + "_apply_heaven_manual_data", + "_heaven_line_checks", + "_resolve_heaven_stock_code", + "heaven_setup", + "_heaven_stock_context", + "_heaven_market_mode", + "_heaven_trend_sources", + "_heaven_trend_quality_issues", + "heaven_personal", + "heaven_hexagram", + "heaven_readings", + "_heaven_reading_identity", + "heaven_interpret", + "_legacy_truncated_heaven_reading", + "_call_heaven_agent", + "_heaven_index_context", + "_aggregate_index_context", + "_heaven_sector_context", +} + +HEAVEN_REPOSITORY_METHODS = { + "_heaven_reading_dict", + "save_heaven_reading", + "list_heaven_readings", + "latest_heaven_reading", + "delete_heaven_reading", +} + +HEAVEN_HTTP_METHODS = {"heaven_hexagram", "heaven_personal", "heaven_interpret"} + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def top_level_definitions(path: Path) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + return { + node.name: ast.dump(node, include_attributes=False) + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + + +class HeavenSliceSourceEquivalenceTests(unittest.TestCase): + def assert_methods_equal( + self, + original_path: Path, + original_class: str, + migrated_path: Path, + migrated_class: str, + expected: set[str], + adapted: set[str] | None = None, + ) -> None: + original = class_methods(original_path, original_class) + migrated = class_methods(migrated_path, migrated_class) + self.assertEqual(set(migrated), expected) + for name in sorted(expected - (adapted or set())): + self.assertEqual(migrated[name], original[name], name) + + def test_heaven_agent_is_an_exact_file(self) -> None: + self.assertEqual( + sha256(ORIGINAL_ROOT / "heaven_agent.py"), + sha256(APP_ROOT / "backend" / "features" / "heaven" / "agent.py"), + ) + + def test_heaven_engine_definitions_are_exact_original_ast(self) -> None: + self.assertEqual( + top_level_definitions(ORIGINAL_ROOT / "heaven_engine.py"), + top_level_definitions( + APP_ROOT / "backend" / "features" / "heaven" / "engine.py" + ), + ) + + def test_compatibility_modules_are_canonical_module_objects(self) -> None: + self.assertIs(heaven_agent, canonical_agent) + self.assertIs(heaven_engine, canonical_engine) + + def test_heaven_service_methods_are_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "server.py", + "DashboardService", + APP_ROOT / "backend" / "features" / "heaven" / "service.py", + "HeavenServiceMixin", + HEAVEN_SERVICE_METHODS, + {"_heaven_reading_identity"}, + ) + source = ( + APP_ROOT / "backend" / "features" / "heaven" / "service.py" + ).read_text(encoding="utf-8") + self.assertIn( + "MarketServiceMixin._display_compact_date(context_date)", source + ) + + def test_heaven_repository_methods_are_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "database.py", + "ReviewDatabase", + APP_ROOT / "backend" / "features" / "heaven" / "repository.py", + "HeavenRepositoryMixin", + HEAVEN_REPOSITORY_METHODS, + ) + + def test_original_classes_no_longer_duplicate_moved_methods(self) -> None: + remaining_service = class_methods( + APP_ROOT / "backend" / "application.py", "DashboardService" + ) + remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase") + remaining_http = class_methods( + APP_ROOT / "backend" / "application.py", "RequestHandler" + ) + self.assertTrue(HEAVEN_SERVICE_METHODS.isdisjoint(remaining_service)) + self.assertTrue(HEAVEN_REPOSITORY_METHODS.isdisjoint(remaining_database)) + self.assertTrue(HEAVEN_HTTP_METHODS.isdisjoint(remaining_http)) + + def test_http_mixin_preserves_all_heaven_endpoints(self) -> None: + methods = class_methods( + APP_ROOT / "backend" / "features" / "heaven" / "http.py", + "HeavenHttpMixin", + ) + self.assertEqual(set(methods), HEAVEN_HTTP_METHODS) + source = ( + APP_ROOT / "backend" / "features" / "heaven" / "http.py" + ).read_text(encoding="utf-8") + self.assertNotIn("SERVICE.", source) + self.assertEqual(source.count("self.application_service.heaven_"), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/migration/evidence/slice-08/README.md b/docs/migration/evidence/slice-08/README.md new file mode 100644 index 0000000..cbfe325 --- /dev/null +++ b/docs/migration/evidence/slice-08/README.md @@ -0,0 +1,69 @@ +# 切片 08:问天、观势、观气与观心 + +> 基线:`2919229`(切片 07) +> 回档标签:`xiaobai-preservation-slice-08-20260731` +> 结论:源码、API、数据库、真实页面、动画与全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动原版问天 Agent、历法/卦象引擎、服务、持久化与三个 HTTP 入口,没有从 +`next/`取用代码,也没有改写六爻、安全门、五运六气、个人合参、铜钱起卦、历史去重或 +LLM 解读逻辑。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/heaven_agent.py` | `app/backend/features/heaven/agent.py` | 根级模块指向同一模块对象 | +| `app/heaven_engine.py` | `app/backend/features/heaven/engine.py` | 根级模块指向同一模块对象 | +| `DashboardService`问天方法 | `app/backend/features/heaven/service.py` | `HeavenServiceMixin` | +| 问天历史持久化 | `app/backend/features/heaven/repository.py` | `HeavenRepositoryMixin` | +| 三个问天 POST 入口 | `app/backend/features/heaven/http.py` | `HeavenHttpMixin` | + +历法引擎搬迁后仍从`app/vendor`及`app/data/iching_zh.json`读取原资源;这是唯一资源路径适配, +顶层函数与类定义未变。旧测试依赖的根级模块继续有效。 + +## 2. 源码等价 + +- `heaven_agent.py`与原版文件 SHA-256 一致。 +- `heaven_engine.py`所有顶层函数和类与原版无位置信息 AST 一致。 +- 20个问天服务方法中19个与原版 AST 一致;`_heaven_reading_identity`仅将旧巨型类名 + `DashboardService`替换为已归位的`MarketServiceMixin`静态日期格式化方法。 +- 5个问天 Repository 方法与原版 AST 一致。 +- 三个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`,状态码、字段和异常语义不变。 +- `DashboardService`、`ReviewDatabase`与`RequestHandler`不再重复保留已迁移实现。 + +## 3. API 与数据库差分 + +- 原版`8788`和迁移版`8789`使用同一正式数据库的两个临时副本。 +- 固定日期的问天初始化、三类历史读取、六爻成卦和个人五行计算共6个接口,状态码与业务 JSON 完全一致。 +- 未调用真实`/api/heaven/interpret`,避免外部模型波动及测试记录写入;提示词和调用链由源码等价与单元测试覆盖。 +- 两版均为62个 schema 对象;`heaven_readings`、`users`、`system_settings`、`llm_usage`、 + `user_birth_profiles`和`sector_phase_overrides`逐行一致。 +- 差分只在系统临时目录副本运行,正式数据库未写入问天记录或 LLM 用量。 + +## 4. 真实浏览器与动画检查 + +- 1920×1080视口逐页检查观势、观气、观心,无横向溢出,页面滚动边界可用。 +- 观势保留星空、三才六爻与八卦待载入场景;观气生成100个星点,五运六气环动画名为`wt-spin`, + 五行行业默认折叠;观心呼吸波纹、香火燃烧和阶段文字均持续运行。 +- 夜间模式问天背景为`rgb(11, 17, 32)`,全页无控制台错误。 +- 没有触发真实解势、解运或解卦调用,加载动画的静态资产及脚本未改动。 +- 截图 SHA-256: + - `heaven-trend-1080.png`:`9e70d9cbfd741e74d3c4511c0e76ef8e625a6a31fd5d6bde014103f807bcc76e` + - `heaven-fortune-1080.png`:`e4cdf0e0fc1d9325724654a781febed1d509e49ddccecb3355803b5e9f1d2f5c` + - `heaven-heart-breathing-1080.png`:`3493a59b9032c1a8a1ba7da609b499c736170d2c7947b3aa0ac4c8b95d981ff5` + - `heaven-heart-dark-1080.png`:`3d346fea6b593fa4e43037f5fc68eda1a3f3446a223c2022d9084922f646a088` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 280项通过 | +| `python -m unittest tests.test_preservation_slice_heaven -q` | 7项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过(2.2分钟) | +| `git diff --check` | 通过 | + +- 复盘助手、交易日志、自选、笔记与提醒属于切片09,本切片不提前移动。 +- 前端 DOM、页面 JS、CSS和移动端行为未改动,统一归档延至切片10。 +- `static/heaven-loading.js`是否失效仍不确定,继续保留到切片11试删。 +- 没有删除待定代码、没有修改正式数据库、没有测试或切换 Docker/NAS。 diff --git a/docs/migration/evidence/slice-08/api-diff.json b/docs/migration/evidence/slice-08/api-diff.json new file mode 100644 index 0000000..bfd0941 --- /dev/null +++ b/docs/migration/evidence/slice-08/api-diff.json @@ -0,0 +1,71 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "heaven setup awaiting stock selection", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92", + "migrated_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92", + "equal": true, + "first_difference": null + }, + { + "name": "trend interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "equal": true, + "first_difference": null + }, + { + "name": "fortune interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=fortune&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0", + "migrated_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0", + "equal": true, + "first_difference": null + }, + { + "name": "heart interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=heart&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072", + "migrated_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072", + "equal": true, + "first_difference": null + }, + { + "name": "six-line hexagram calculation", + "method": "POST", + "endpoint": "/api/heaven/hexagram", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960", + "migrated_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960", + "equal": true, + "first_difference": null + }, + { + "name": "personal five-phase calculation", + "method": "POST", + "endpoint": "/api/heaven/personal", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e", + "migrated_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-08/api-requests.json b/docs/migration/evidence/slice-08/api-requests.json new file mode 100644 index 0000000..9e94377 --- /dev/null +++ b/docs/migration/evidence/slice-08/api-requests.json @@ -0,0 +1,42 @@ +[ + { + "name": "heaven setup awaiting stock selection", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-29", + "payload": null + }, + { + "name": "trend interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "payload": null + }, + { + "name": "fortune interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=fortune&limit=20", + "payload": null + }, + { + "name": "heart interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=heart&limit=20", + "payload": null + }, + { + "name": "six-line hexagram calculation", + "method": "POST", + "endpoint": "/api/heaven/hexagram", + "payload": { + "lines": [7, 8, 9, 6, 7, 8] + } + }, + { + "name": "personal five-phase calculation", + "method": "POST", + "endpoint": "/api/heaven/personal", + "payload": { + "trade_date": "2026-07-29" + } + } +] diff --git a/docs/migration/evidence/slice-08/database-diff.json b/docs/migration/evidence/slice-08/database-diff.json new file mode 100644 index 0000000..6258c71 --- /dev/null +++ b/docs/migration/evidence/slice-08/database-diff.json @@ -0,0 +1,59 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "heaven_readings", + "original_count": 31, + "migrated_count": 31, + "original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "equal": true + }, + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true + }, + { + "table": "user_birth_profiles", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "equal": true + }, + { + "table": "sector_phase_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-08/heaven-fortune-1080.png b/docs/migration/evidence/slice-08/heaven-fortune-1080.png new file mode 100644 index 0000000..62ad91c Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-fortune-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png b/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png new file mode 100644 index 0000000..66f4295 Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png b/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png new file mode 100644 index 0000000..5057971 Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-trend-1080.png b/docs/migration/evidence/slice-08/heaven-trend-1080.png new file mode 100644 index 0000000..8ba600e Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-trend-1080.png differ diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index 6e02594..4ca70fb 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T04:17:23+08:00", + "updated_at": "2026-07-31T08:55:17+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-08-heaven-trend-fortune-heart", - "last_completed_slice": "slice-07-mentor-skills-llm-streaming", - "last_checkpoint": "xiaobai-preservation-slice-07-20260731", - "next_action": "capture_slice-08_heaven_trend_fortune_heart_and_animation_contracts_then_move_original_implementations", + "current_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant", + "last_completed_slice": "slice-08-heaven-trend-fortune-heart", + "last_checkpoint": "xiaobai-preservation-slice-08-20260731", + "next_action": "capture_slice-09_review_private_data_and_assistant_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index a1fea69..cf44cf7 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片07“问师、模型 Skill 与 LLM 流式链路”已完成 +> 当前状态:正式迁移,切片08“问天、观势、观气与观心”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -27,6 +27,7 @@ | 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 | | 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 | | 2026-07-31 | `xiaobai-preservation-slice-07-20260731` | 问师、模型Skill与LLM流式链路原实现归位 | 自动、API、数据库、Skill与浏览器差分通过,进入切片08 | +| 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 | ## 资产处置登记 @@ -58,6 +59,8 @@ | 问师消息与偏好方法 | 持久化 | 用户对话、置顶和排序 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/mentor/repository.py` | 5个方法AST一致;相关表逐行一致 | 已移动 | | `llm_stream.py`与模型访问方法 | 公共模型能力 | 问师、问天、复盘助手与策略编译 | 移入唯一模型边界并保留兼容别名 | `app/backend/llm/` | 流式文件哈希一致;21个服务方法与既有用户边界一致 | 已移动 | | 公开`游资skills` | 运行资产 | 问师模型库 | 原样保留 | `app/游资skills/` | 190个文件逐路径和SHA-256一致 | 已复制 | +| `heaven_agent.py`、`heaven_engine.py`与问天服务 | 业务计算 | 观势、观气、观心 | 机械移动并保留兼容别名 | `app/backend/features/heaven/` | Agent文件哈希、引擎定义AST、20个服务方法、6个API及真实动画页面等价 | 已移动 | +| 问天历史方法 | 持久化 | 三类解读历史与当日解运复用 | 按职责机械移动 | `app/backend/features/heaven/repository.py` | 5个方法AST一致;62个schema对象及6张关键表逐行一致 | 已移动 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -145,6 +148,16 @@ - 回档:标签`xiaobai-preservation-slice-07-20260731`。 - 完整证据:`docs/migration/evidence/slice-07/README.md`。 +已完成切片:`slice-08-heaven-trend-fortune-heart`。 + +- 原版基线:提交`2919229`,即切片07回档点。 +- 迁移范围:问天Agent、历法/卦象引擎、20个服务方法、5个持久化方法及3个HTTP入口。 +- 兼容边界:根级`heaven_agent.py`和`heaven_engine.py`指向正式模块对象;原动画、DOM、JS与CSS未改动。 +- API与数据库:6个固定输入真实API完全一致;62个schema对象及6张关键表逐行一致。 +- 验收:原版231项、迁移版280项Python测试、7项切片源码等价测试、45项Playwright及1080P三模式动画页面通过。 +- 回档:标签`xiaobai-preservation-slice-08-20260731`。 +- 完整证据:`docs/migration/evidence/slice-08/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |