from __future__ import annotations import copy from typing import Any from backend.bootstrap.config import validate_text from backend.data.providers.tushare_client import _sector_coverage_issue from backend.features.heaven.engine import _market_line_scores, _score_to_line class HeavenManualMixin: @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