rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -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 "势衰宜守"
|
||||
Reference in New Issue
Block a user