rebuild(stage-11): deliver deterministic heaven workflows

This commit is contained in:
leefer
2026-07-30 07:08:13 +08:00
parent aa3f02bd59
commit 35ae079de7
49 changed files with 7208 additions and 39 deletions
+353
View File
@@ -0,0 +1,353 @@
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 "势衰宜守"