refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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.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.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 "",
|
||||
},
|
||||
}
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user