Files
xiaobaifupan/app/backend/features/heaven/service.py
T

1304 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import copy
import json
import re
from datetime import date, datetime, timedelta
from typing import Any
from backend.bootstrap.config import (
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from backend.data.providers.tushare_client import TushareError, _sector_coverage_issue
from backend.features.heaven.agent import HeavenAgentError, interpret_heaven
from backend.features.heaven.engine import (
_market_line_scores,
_score_to_line,
build_five_phase_field,
build_market_hexagram,
hexagram_from_lines,
)
from backend.features.market import MarketServiceMixin
class HeavenServiceMixin:
@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
def _resolve_heaven_stock_code(self, query: str) -> str:
raw = validate_text(query, "股票代码或名称", 30, required=True)
code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
if code_match:
return validate_stock_code(code_match.group(1))
candidates = self.database.search_stock_master(raw)
exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
if not exact and self.configured:
try:
rows = self._tushare_client().query(
"stock_basic",
{"name": raw, "list_status": "L"},
"ts_code,symbol,name,industry,market,list_date",
)
except TushareError:
rows = []
if rows:
self.database.upsert_stock_master(rows)
candidates = self.database.search_stock_master(raw)
exact = [
item
for item in candidates
if str(item.get("name") or "").casefold() == raw.casefold()
]
matches = exact or candidates
if len(matches) == 1:
return validate_stock_code(str(matches[0].get("code") or ""))
if len(matches) > 1:
choices = "、".join(
f"{item.get('name') or '--'}{item.get('code') or '--'}"
for item in matches[:5]
)
raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。")
raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。")
def heaven_setup(
self,
trade_date: str,
sector_name: str = "",
stock_code: str = "",
manual_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
dashboard = self.get_dashboard(normalized_date)
data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date))
recent_history = self.database.snapshot_summaries(data_date, 10)
market_mode = self._heaven_market_mode(data_date, dashboard)
manual_data = self._validate_heaven_manual_data(manual_data, market_mode)
index_context = self._heaven_index_context(data_date, dashboard, market_mode)
external_stock = None
normalized_stock_code = ""
if stock_code.strip():
normalized_stock_code = self._resolve_heaven_stock_code(stock_code)
external_stock = self._heaven_stock_context(
normalized_stock_code,
data_date,
dashboard,
market_mode,
)
external_sector = None
if normalized_stock_code and self.configured:
external_sector = self._heaven_sector_context(
normalized_stock_code,
data_date,
market_mode,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data(
dashboard,
index_context,
external_sector,
external_stock,
manual_data,
market_mode,
data_date,
normalized_stock_code,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
sector_input = str((external_sector or {}).get("name") or sector_name.strip())
if not normalized_stock_code:
data_checks = []
chart = {
"available": False,
"selection_required": True,
"data_trade_date": data_date,
"sector": "",
"sector_code": "",
"sector_taxonomy": "",
"stock": {"code": "", "name": "", "status": ""},
"quality": {
"status": "awaiting_selection",
"issues": [],
"principle": "",
"sources": [],
},
"index_context": index_context,
}
else:
data_checks = self._heaven_line_checks(
data_date,
dashboard,
recent_history,
index_context,
external_sector or {},
external_stock or {},
market_mode,
manual_data,
)
quality_issues = [
f"{check['position']}·{check['layer']}{''.join(check['reasons'])}"
for check in data_checks
if not check["passed"]
]
if quality_issues:
chart = {
"available": False,
"selection_required": False,
"data_trade_date": data_date,
"sector": str((external_sector or {}).get("name") or sector_input or "--"),
"sector_code": str((external_sector or {}).get("code") or ""),
"sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""),
"stock": {
"code": normalized_stock_code,
"name": str((external_stock or {}).get("name") or "--"),
"status": str((external_stock or {}).get("status") or ""),
},
"quality": {
"status": "blocked",
"issues": quality_issues,
"principle": "六爻任一层缺少同日、同口径的有效数据,本系统不成卦。",
"sources": self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
},
"index_context": index_context,
}
else:
chart = build_market_hexagram(
dashboard,
recent_history,
index_context,
sector_input,
normalized_stock_code,
external_stock,
external_sector,
)
chart["available"] = True
chart["selection_required"] = False
manual_active = any(check["status"] == "manual" for check in data_checks)
chart["quality"] = {
"status": "manual" if manual_active else "verified",
"issues": [],
"principle": (
"自动行情与用户补充数据均已通过同一套量化公式校验。"
if manual_active
else "指数、板块、个股均已通过同日同口径校验。"
),
"sources": [
*self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
*([{
"lines": "补录爻位",
"layer": "用户补充",
"realtime": market_mode == "intraday",
"detail": str(manual_data.get("note") or "量化数据经原公式重新计算"),
}] if manual_active else []),
],
}
chart["data_checks"] = data_checks
chart["manual_data"] = manual_data
sector_phase_overrides = self.database.list_sector_phase_overrides()
field = build_five_phase_field(
normalized_date,
sector_phase_overrides,
)
personal_profile = self.account_personal_field(
normalized_date,
field,
public=True,
)
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
return {
"trade_date": data_date,
"calendar_date": normalized_date,
"market_mode": market_mode,
"chart": chart,
"field": field,
"personal_profile": personal_profile,
"daily_fortune_reading": daily_fortune_reading,
"sector_phase_overrides": [
{"name": name, "element": element}
for name, element in sector_phase_overrides.items()
],
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
}
def _heaven_stock_context(
self,
stock_code: str,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str,
) -> dict[str, Any]:
"""Return the only stock contract accepted by heaven trend."""
pool_row = next(
(
dict(row) for key in ("limits", "broken", "down_limits")
for row in dashboard.get(key) or []
if str(row.get("code") or "") == stock_code
),
{},
)
if market_mode == "intraday":
if self.configured:
try:
quote = self._tushare_client().realtime_stock_quote(
tushare_code(stock_code),
trade_date,
)
return {
**quote,
"status": pool_row.get("status") or "普通",
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"precise": True,
}
except TushareError:
pass
if pool_row:
return {
**pool_row,
"data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
"trade_date": trade_date,
"realtime": bool(dashboard.get("meta", {}).get("realtime")),
"precise": False,
}
return {
"code": stock_code,
"name": "--",
"sector": "其他",
"trade_date": trade_date,
"realtime": False,
"precise": False,
}
detail = self.get_stock_detail(stock_code, trade_date, force=True)
detail_meta = detail.get("meta") or {}
stock = detail.get("stock") or {}
resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
source = str(detail_meta.get("source") or "")
return {
"code": stock_code,
"name": stock.get("name") or pool_row.get("name") or "--",
"sector": stock.get("industry") or pool_row.get("sector") or "其他",
"status": pool_row.get("status") or "普通",
"change": stock.get("change") or 0,
"turnover_rate": stock.get("turnover_rate") or 0,
"amount_billion": stock.get("amount_billion") or 0,
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"data_source": source,
"trade_date": resolved_date,
"realtime": False,
"precise": source == "tushare" and resolved_date == trade_date,
}
@staticmethod
def _heaven_market_mode(
trade_date: str,
dashboard: dict[str, Any],
now: datetime | None = None,
) -> str:
"""区分盘中、今日收盘和历史,避免把 rt_k 数据来源误当成交易状态。"""
now = now or datetime.now().astimezone()
if trade_date != now.strftime("%Y%m%d"):
return "historical"
meta = dashboard.get("meta") or {}
status = str(meta.get("market_status") or "").lower()
local_time = now.time().replace(tzinfo=None)
if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time():
return "closed"
if status in {"trading", "auction", "pre_open"} or (
bool(meta.get("realtime"))
and local_time >= datetime.strptime("09:15", "%H:%M").time()
):
return "intraday"
return "historical"
@staticmethod
def _heaven_trend_sources(
trade_date: str,
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
) -> list[dict[str, Any]]:
sector = sector or {}
stock = stock or {}
return [
{
"lines": "五爻、上爻",
"layer": "指数",
"source": index_context.get("source") or "unavailable",
"trade_date": index_context.get("trade_date") or "",
"realtime": bool(index_context.get("realtime")),
"detail": f"三大指数 {len(index_context.get('indices') or [])}/3",
},
{
"lines": "三爻、四爻",
"layer": "行业",
"source": sector.get("source") or "unavailable",
"trade_date": sector.get("trade_date") or "",
"realtime": bool(sector.get("realtime")),
"detail": (
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
),
},
{
"lines": "初爻、二爻",
"layer": "个股",
"source": stock.get("data_source") or "unavailable",
"trade_date": stock.get("trade_date") or trade_date,
"realtime": bool(stock.get("realtime")),
"detail": (
f"{stock.get('name') or '--'};换手基准 "
f"{stock.get('capital_trade_date') or '--'}"
),
},
]
@staticmethod
def _heaven_trend_quality_issues(
trade_date: str,
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
market_mode: str = "historical",
) -> list[str]:
issues: list[str] = []
intraday = market_mode == "intraday"
closed = market_mode == "closed"
if intraday:
meta = dashboard.get("meta") or {}
market_status = str(meta.get("market_status") or "")
now = datetime.now().astimezone()
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
except ValueError:
snapshot_age = float("inf")
if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120:
issues.append("主行情快照超过2分钟,请点击顶部刷新")
# 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将
# rt_k 快照替换成同日盘后日线而不带该字段;六爻数据本身的日期、
# 完整性和来源校验已足以判断是否可以成卦。
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_row_dates = {
str(row.get("trade_date") or "").replace("-", "") for row in index_rows
}
if not index_context.get("precise") or len(index_rows) < 3:
issues.append("指数层缺少三大指数的有效行情")
elif index_date != trade_date or index_row_dates != {trade_date}:
issues.append("指数行情与目标交易日不一致")
elif intraday and not index_context.get("realtime"):
issues.append("盘中指数层缺少可核验的实时行情")
elif not intraday and (
index_context.get("realtime")
or str(index_context.get("source") or "") != "tushare"
):
issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线")
sector = sector or {}
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,
)
if not sector:
issues.append("行业层缺少申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
issues.append("行业层必须使用申万二级行业分类")
elif sector_date != trade_date:
issues.append("行业行情与目标交易日不一致")
elif intraday and not sector.get("realtime"):
issues.append("盘中行业层缺少申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
issues.append("历史行业层不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
issues.append("收盘行业层缺少15:00最终快照")
if not sector.get("inner_precise", sector.get("precise")):
issues.append("行业内核缺少可核验的成分行情")
if not sector.get("outer_precise", sector.get("precise")):
issues.append("行业外显缺少申万官方行情")
if sector and sector_coverage_issue:
issues.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
issues.append("行业内核缺少相对全市场换手活跃度")
stock = stock or {}
stock_date = str(stock.get("trade_date") or "").replace("-", "")
if not stock or not stock.get("code"):
issues.append("个股层尚未载入有效标的")
elif not stock.get("precise"):
issues.append("个股层缺少可核验的行情数据")
elif stock_date != trade_date:
issues.append("个股行情与目标交易日不一致")
elif intraday and not stock.get("realtime"):
issues.append("盘中个股层不是 rt_k 实时行情")
elif not intraday and (
stock.get("realtime")
or str(stock.get("data_source") or "") != "tushare"
):
issues.append("历史/收盘个股层必须使用 Tushare 官方日线")
if intraday and stock and not stock.get("turnover_source"):
issues.append("个股内核缺少可核验的实时换手率")
elif intraday and stock.get("turnover_source") == "unavailable":
issues.append("个股内核缺少流通股本,无法计算实时换手率")
if intraday and stock.get("activity_source") == "unavailable":
issues.append("个股内核缺少近5日量能基准")
elif intraday and not stock.get("activity_source"):
issues.append("个股内核缺少同时间进度量能")
return issues
def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
field = build_five_phase_field(
trade_date,
self.database.list_sector_phase_overrides(),
)
personal = self.account_personal_field(trade_date, field, public=True)
if not personal:
raise ValueError("请先在账号设置中保存个人命理资料。")
return personal
def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]:
if not isinstance(raw_lines, list):
raise ValueError("六爻起卦结果格式不正确。")
try:
lines = [int(value) for value in raw_lines]
except (TypeError, ValueError) as exc:
raise ValueError("六爻必须由六、七、八、九组成。") from exc
return hexagram_from_lines(lines)
def heaven_readings(
self, mode: str, context_date: str = "", limit: int = 100
) -> dict[str, Any]:
mode = str(mode or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("解读记录类型不正确。")
normalized_date = normalize_date(context_date) if context_date else ""
return {
"mode": mode,
"items": self.database.list_heaven_readings(
self.current_user_id, mode, normalized_date, limit
),
}
@staticmethod
def _heaven_reading_identity(
mode: str, context_date: str, context: dict[str, Any]
) -> tuple[str, str]:
display_date = MarketServiceMixin._display_compact_date(context_date)
if mode == "trend":
stock = (context.get("selected_focus") or {}).get("stock") or {}
code = str(stock.get("code") or "").strip()
name = str(stock.get("name") or "").strip()
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
subject = " ".join(item for item in (code, name) if item) or "观势"
detail = f"{display_date} · {hexagram.get('name') or '--'}{transformed.get('name') or '--'}"
return subject, detail
if mode == "fortune":
field = context.get("five_phase_field") or {}
pillars = field.get("pillars") or {}
dominant = (field.get("balance") or [{}])[0]
subject = f"{display_date} 观气"
detail = (
f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
)
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
mode = str(payload.get("mode") or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
)
if self._legacy_truncated_heaven_reading(existing):
self.database.delete_heaven_reading(
self.current_user_id, int(existing["id"])
)
existing = None
if existing:
return {
"answer": existing["answer"],
"mode": mode,
"compiler": "stored",
"notice": "",
"reading": existing,
"reused": True,
}
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
str(payload.get("sector") or ""),
str(payload.get("stock_code") or ""),
payload.get("manual_data"),
)
if mode == "trend":
chart = setup["chart"]
if not chart.get("available"):
issues = "".join((chart.get("quality") or {}).get("issues") or [])
raise ValueError(f"观势数据未通过六爻校验,暂不解势:{issues}")
hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False))
for line in hexagram_context.get("lines", []):
line.pop("evidence", None)
line.pop("score", None)
line.pop("talent", None)
line.pop("layer", None)
line.pop("role", None)
if not line.get("moving"):
line.pop("text", None)
line.pop("image", None)
line.pop("line_name", None)
context = {
"data_trade_date": setup["trade_date"],
"selected_focus": {
"sector": chart.get("sector") or "",
"stock": chart.get("stock") or {},
},
"hexagram": hexagram_context,
"movement": chart.get("movement") or {},
}
else:
personal_profile = self.account_personal_field(
setup["calendar_date"],
setup["field"],
public=False,
)
fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
catalog = fortune_field.pop("sector_catalog", [])
dominant_elements = {
item.get("element") for item in fortune_field.get("balance", [])[:2]
}
fortune_field["industry_affinity"] = [
{
"element": group.get("element"),
"examples": [
item.get("name")
for item in group.get("industries", [])[:8]
if item.get("name")
],
}
for group in catalog
if group.get("element") in dominant_elements
]
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": fortune_field,
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
dedupe_key = (
f"fortune:{context_date}"
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
context_date,
subject,
subject_detail,
str(result.get("answer") or ""),
context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@staticmethod
def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
result = self.llm_gateway.call(
f"heaven_{mode}",
f"heaven-{mode}-v1",
lambda profile: interpret_heaven(
mode,
context,
profile.api_key,
profile.base_url,
profile.model,
),
(HeavenAgentError,),
)
return result.value, result.role
def _heaven_index_context(
self,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str = "historical",
) -> dict[str, Any]:
cached = self.database.get_data_snapshot("heaven_indices", trade_date)
cached_valid = False
if cached:
cached_rows = list(cached.get("indices") or [])
cached_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in cached_rows
}
cached_valid = (
len(cached_rows) == 3
and cached_dates == {trade_date}
and bool(cached.get("precise"))
and not cached.get("realtime")
and str(cached.get("source") or "") == "tushare"
and int(cached.get("schema_version") or 0) >= 3
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
error = "Tushare Token 未配置"
else:
try:
client = self._tushare_client()
if market_mode == "intraday":
payload = self._aggregate_index_context(trade_date)
payload["schema_version"] = 3
return payload
payload = client.market_indices(trade_date)
payload["schema_version"] = 3
if market_mode == "closed":
payload["finalized"] = True
self.database.save_data_snapshot(
"heaven_indices",
trade_date,
str(payload.get("source") or "tushare"),
payload,
)
return payload
except Exception as exc:
error = str(exc)
overview = dashboard.get("overview") or {}
up_count = float(overview.get("up_count") or 0)
down_count = float(overview.get("down_count") or 0)
breadth = (up_count - down_count) / max(up_count + down_count, 1)
return {
"source": "market_breadth_proxy",
"trade_date": trade_date,
"realtime": False,
"precise": False,
"schema_version": 3,
"notice": f"指数数据不可用,当前以市场宽度代理:{error}",
"indices": [],
"aggregate": {
"average_pct_chg": round(breadth * 2.5, 3),
"average_return_5d": 0,
"average_return_20d": 0,
},
}
def _aggregate_index_context(
self,
trade_date: str,
tushare_error: str = "",
) -> dict[str, Any]:
quotes = self.realtime_aggregator.tencent_indices()
epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
quote_dates = {
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
for epoch in epochs if epoch
}
if len(quotes) != 3 or quote_dates != {trade_date}:
raise ValueError("腾讯三大指数日期与目标交易日不一致")
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
if max(epochs) - min(epochs) > max_skew:
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
code_map = {
"000001": "000001.SH",
"399001": "399001.SZ",
"399006": "399006.SZ",
}
client = self._tushare_client()
indices = []
start_date = (
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
).strftime("%Y%m%d")
for quote in quotes:
ts_code = code_map[str(quote.get("code") or "")]
history = client.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
completed_closes = [
float(item.get("close") or 0)
for item in history
if str(item.get("trade_date") or "") < trade_date
and float(item.get("close") or 0) > 0
]
close_5d = (
completed_closes[-5]
if len(completed_closes) >= 5
else completed_closes[0] if completed_closes else 0
)
close = float(quote.get("price") or 0)
indices.append(
{
"ts_code": ts_code,
"name": quote.get("name") or ts_code,
"trade_date": trade_date,
"close": close,
"pct_chg": round(float(quote.get("change") or 0), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": 0,
"amount_billion": float(quote.get("amount_billion") or 0),
"quote_time": quote.get("quote_time") or "",
}
)
return {
"trade_date": trade_date,
"source": "+".join(
sorted({str(item.get("source") or "web_quote") for item in quotes})
+ ["tushare_index_daily"]
),
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(
sum(item["pct_chg"] for item in indices) / len(indices), 3
),
"average_return_5d": round(
sum(item["return_5d"] for item in indices) / len(indices), 3
),
"average_return_20d": 0,
},
"quote_time_skew_seconds": max(epochs) - min(epochs),
"notice": (
"指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。"
+ (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "")
),
}
def _heaven_sector_context(
self,
identifier: str,
trade_date: str,
market_mode: str = "historical",
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
cached_valid = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached.get("inner_precise", cached.get("precise"))
and cached.get("outer_precise", cached.get("precise"))
and not cached.get("realtime")
and int(cached.get("schema_version") or 0) >= 6
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
return None
try:
payload = self._tushare_client().sw_sector_snapshot(
tushare_code(identifier),
trade_date,
realtime_expected=market_mode == "intraday",
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_valid:
return cached
return {
"name": "",
"code": "",
"taxonomy": "sw_l2",
"source": "tushare",
"trade_date": trade_date,
"realtime": market_mode == "intraday",
"precise": False,
"inner_precise": False,
"outer_precise": False,
"coverage": 0,
"member_count": 0,
"quote_count": 0,
"error": f"申万二级行业数据获取失败:{exc}",
}
if not payload.get("realtime") and payload.get("precise"):
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
return payload