354 lines
13 KiB
Python
354 lines
13 KiB
Python
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 "势衰宜守"
|