413 lines
24 KiB
Python
413 lines
24 KiB
Python
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
|