1304 lines
62 KiB
Python
1304 lines
62 KiB
Python
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
|