from __future__ import annotations from copy import deepcopy from pathlib import Path from typing import Any from backend.features.heaven.hexagram import LINE_POSITIONS, from_lines, line_for_score LINE_META = ( ("地", "内", "个股内核"), ("地", "外", "个股外显"), ("人", "内", "行业内核"), ("人", "外", "行业外显"), ("天", "内", "市场内核"), ("天", "外", "指数外显"), ) MANUAL_FIELDS = { "sector.name", "sector.change", "sector.up_count", "sector.down_count", "sector.member_count", "sector.quoted_count", "sector.coverage", "sector.member_equal_change", "sector.relative_turnover", "sector.leader", "sector.leading_pct", } class TrendDataError(RuntimeError): def __init__(self, checks: list[dict[str, Any]], payload: dict[str, Any]) -> None: super().__init__("六爻量化数据未全部通过安全门,暂不成卦。") self.checks = checks self.payload = payload def calculate( payload: dict[str, Any], data_path: Path, manual: dict[str, Any] | None = None, ) -> dict[str, Any]: normalized, manual_paths = apply_manual(payload, manual or {}) checks = validate(normalized, manual_paths) if any(not item["passed"] for item in checks): raise TrendDataError(checks, normalized) scores = _scores(normalized) values = [line_for_score(item["score"]) for item in scores] hexagram = from_lines(values, data_path) for index, line in enumerate(hexagram["lines"]): talent, layer, role = LINE_META[index] line.update( talent=talent, layer=layer, role=role, score=round(scores[index]["score"], 4), evidence=scores[index]["evidence"], validation=checks[index], ) average = sum(item["score"] for item in scores) / 6 moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]] return { "trade_date": normalized["trade_date"], "stock": normalized["stock"], "sector": normalized["sector"], "hexagram": hexagram, "movement": { "moving_names": moving_names, "label": ( f"{'、'.join(moving_names)}动,{hexagram['name']}之{hexagram['transformed']['name']}" if moving_names else f"无动爻,守{hexagram['name']}本势" ), }, "momentum_score": round(average * 100), "momentum_label": _momentum_label(average), "checks": checks, "manual_fields": sorted(manual_paths), "notice": "卦象来自客观行情的固定量化映射,仅供传统文化与娱乐化观察。", } def apply_manual( payload: dict[str, Any], manual: dict[str, Any] ) -> tuple[dict[str, Any], set[str]]: result = deepcopy(payload) failed_paths = _failed_paths(result) applied: set[str] = set() for path, value in _flatten(manual).items(): if path not in MANUAL_FIELDS or path not in failed_paths or value in (None, ""): continue section, key = path.split(".", 1) result.setdefault(section, {})[key] = value applied.add(path) return result, applied def validate(payload: dict[str, Any], manual_paths: set[str] | None = None) -> list[dict[str, Any]]: manual_paths = manual_paths or set() trade_date = str(payload.get("trade_date") or "") stock = payload.get("stock") or {} sector = payload.get("sector") or {} market = payload.get("market") or {} indexes = payload.get("indices") or [] mode = str(payload.get("mode") or "historical") stock_fields = ( ( "change", "amount_percentile", "turnover_rate", "turnover_relative", "volume_activity_ratio", ) if mode == "intraday" else ("change", "amount_percentile", "turnover_rate") ) stock_ok = ( str(stock.get("trade_date") or "") == trade_date and str(stock.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily") and _has(stock, *stock_fields) ) sector_common = ( str(sector.get("trade_date") or trade_date) == trade_date and str(sector.get("taxonomy") or "") == "申万二级" and _has( sector, "change", "up_count", "down_count", "member_count", "quoted_count", "coverage", "leading_pct", ) ) member_count = _number(sector.get("member_count")) quoted_count = _number(sector.get("quoted_count")) coverage = _number(sector.get("coverage")) complete_members = member_count > 0 and quoted_count == member_count and coverage >= 0.98 sufficient_members = ( member_count > 0 and coverage >= 0.98 and quoted_count >= member_count * 0.98 ) sector_mode = str(sector.get("quote_kind") or "") == ( "realtime" if mode == "intraday" else "daily" ) if mode == "intraday": sector_inner = sector_common and sector_mode and _has(sector, "relative_turnover") else: sector_inner = sector_common and sector_mode and _has(sector, "member_equal_change") sector_inner = sector_inner and (complete_members or sufficient_members) sector_outer = sector_common and sector_mode and bool(str(sector.get("name") or "")) market_ok = ( str(market.get("trade_date") or "") == trade_date and str(market.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily") and _has( market, "sentiment_score", "seal_rate", "amount_billion", "average_amount_billion", "up_count", "down_count", "limit_up_count", "limit_down_count", ) ) expected = {"000001.SH", "399001.SZ", "399006.SZ"} present = { str(item.get("identifier") or "") for item in indexes if str(item.get("trade_date") or "") == trade_date and str(item.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily") and item.get("change") is not None } details = ( (stock_ok, "个股交易日、行情类型及成交活跃数据有效", {"stock"}), (stock_ok and _has(stock, "streak", "status"), "个股涨跌、连板和事件状态有效", {"stock"}), (sector_inner, f"申万二级行业有效成分 {int(quoted_count)}/{int(member_count)}", {"sector"}), (sector_outer, "申万二级行业及领涨股涨跌有效", {"sector"}), (market_ok, "市场情绪、封板、成交、宽度和涨跌停结构有效", {"market"}), (present == expected, "上证、深证、创业板三条指数行情完整", {"indices"}), ) checks = [] for index, (passed, message, sections) in enumerate(details): used_manual = any(path.split(".", 1)[0] in sections for path in manual_paths) checks.append( { "position": index + 1, "position_name": LINE_POSITIONS[index], "role": LINE_META[index][2], "passed": bool(passed), "source": "manual" if used_manual else "automatic", "message": message if passed else _failure_message(index, payload), } ) return checks def _scores(payload: dict[str, Any]) -> list[dict[str, Any]]: stock = payload["stock"] sector = payload["sector"] market = payload["market"] mode = payload.get("mode") or "historical" amount = _clamp(_number(stock["amount_percentile"]) / 100, 0, 1) if mode == "intraday": relative_turnover = _clamp((_number(stock["turnover_relative"]) - 1) / 1.5) activity = _clamp((_number(stock["volume_activity_ratio"]) - 1) / 1.5) stock_inner = (amount * 2 - 1) * 0.35 + relative_turnover * 0.35 + activity * 0.30 else: turnover = _clamp(_number(stock["turnover_rate"]) / 20, 0, 1) seal = _clamp(_number(stock.get("seal_amount_million")) / 15000, 0, 1) stability = 1 - _clamp(_number(stock.get("open_times")) / 6, 0, 1) stock_inner = (amount * 0.32 + turnover * 0.22 + seal * 0.25 + stability * 0.21) * 2 - 1 adjustment = -0.7 if stock["status"] == "跌停" else -0.25 if stock["status"] == "炸板" else 0.15 stock_outer = _clamp( _clamp(_number(stock["change"]) / 10) * 0.7 + _clamp(_number(stock["streak"]) / 5, 0, 1) * 0.2 + adjustment ) up = _number(sector["up_count"]) down = _number(sector["down_count"]) breadth = _clamp((up - down) / max(up + down, 1)) leader = _clamp(_number(sector["leading_pct"]) / 10) if mode == "intraday": relative = _clamp((_number(sector["relative_turnover"]) - 1) / 1.5) sector_inner = breadth * 0.6 + relative * 0.4 else: equal_change = _clamp(_number(sector["member_equal_change"]) / 5) sector_inner = breadth * 0.6 + equal_change * 0.35 + leader * 0.05 sector_outer = _clamp(_number(sector["change"]) / 5) * 0.9 + leader * 0.1 sentiment = _clamp(_number(market["sentiment_score"]) / 100, 0, 1) * 2 - 1 seal_rate = _clamp(_number(market["seal_rate"]) / 100, 0, 1) * 2 - 1 amount_change = _clamp( (_number(market["amount_billion"]) / max(_number(market["average_amount_billion"]), 1) - 1) * 3 ) market_up, market_down = _number(market["up_count"]), _number(market["down_count"]) market_breadth = _clamp((market_up / max(market_up + market_down, 1) - 0.5) * 2) limit_up = _number(market["limit_up_count"]) limit_down = _number(market["limit_down_count"]) limit_balance = _clamp((limit_up - limit_down) / max(limit_up + limit_down, 1)) market_inner = ( sentiment * 0.35 + seal_rate * 0.20 + amount_change * 0.20 + market_breadth * 0.15 + limit_balance * 0.10 ) index_change = sum(_number(item["change"]) for item in payload["indices"]) / 3 return [ { "score": _clamp(stock_inner), "evidence": ( [ f"成交额分位 {amount * 100:.0f}%", f"相对换手 {_number(stock['turnover_relative']):.2f}", f"同进度量能 {_number(stock['volume_activity_ratio']):.2f}", ] if mode == "intraday" else [ f"成交额分位 {amount * 100:.0f}%", f"换手率 {_number(stock['turnover_rate']):.2f}%", ] ), }, { "score": _clamp(stock_outer), "evidence": [ f"涨跌 {_number(stock['change']):+.2f}%", f"状态 {stock['status'] or '普通'}", ], }, { "score": _clamp(sector_inner), "evidence": [ f"上涨 {int(up)} / 下跌 {int(down)}", "有效成分 " f"{int(_number(sector['quoted_count']))}/" f"{int(_number(sector['member_count']))}", ], }, { "score": _clamp(sector_outer), "evidence": [ f"行业涨跌 {_number(sector['change']):+.2f}%", f"领涨股 {_number(sector['leading_pct']):+.2f}%", ], }, { "score": _clamp(market_inner), "evidence": [ f"情绪 {_number(market['sentiment_score']):.0f}", f"封板率 {_number(market['seal_rate']):.1f}%", ], }, {"score": _clamp(index_change / 3), "evidence": [f"三大指数平均 {index_change:+.2f}%"]}, ] def _failed_paths(payload: dict[str, Any]) -> set[str]: sector = payload.get("sector") or {} return {path for path in MANUAL_FIELDS if sector.get(path.split(".", 1)[1]) in (None, "")} def _flatten(value: dict[str, Any], prefix: str = "") -> dict[str, Any]: result: dict[str, Any] = {} for key, item in value.items(): path = f"{prefix}.{key}" if prefix else key if isinstance(item, dict): result.update(_flatten(item, path)) else: result[path] = item return result def _has(value: dict[str, Any], *keys: str) -> bool: return all(key in value and value[key] is not None and value[key] != "" for key in keys) def _failure_message(index: int, payload: dict[str, Any]) -> str: messages = ( "个股成交活跃或交易日期数据缺失", "个股涨跌、连板或事件状态缺失", "申万二级行业成分宽度、覆盖率或换手数据缺失", "申万二级行业涨跌或领涨股涨跌缺失", "市场情绪、成交或宽度数据缺失", "指数层缺少三大指数的有效行情", ) return messages[index] def _clamp(value: float, lower: float = -1, upper: float = 1) -> float: return max(lower, min(upper, value)) def _number(value: Any) -> float: try: return float(value) except (TypeError, ValueError): return 0.0 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 "势衰宜守"