371 lines
16 KiB
Python
371 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from backend.bootstrap.config import normalize_date
|
||
from backend.data.providers.tushare_client import _sector_coverage_issue
|
||
from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS
|
||
from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram
|
||
|
||
|
||
class HeavenTrendMixin:
|
||
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._reusable_daily_fortune_reading(normalized_date)
|
||
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 _reusable_daily_fortune_reading(
|
||
self, context_date: str
|
||
) -> dict[str, Any] | None:
|
||
reading = self.database.latest_heaven_reading(
|
||
self.current_user_id, "fortune", context_date
|
||
)
|
||
if not reading or self._legacy_truncated_heaven_reading(reading):
|
||
return None
|
||
version = self.database.heaven_reading_interpretation_version(
|
||
self.current_user_id, int(reading["id"])
|
||
)
|
||
if version != HEAVEN_PROMPT_VERSIONS["fortune"]:
|
||
return None
|
||
return reading
|
||
|
||
@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
|