fix(HEL-494): 修复个股缺失指标、问天遮罩、四爻外显并回补250日K

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-08 16:26:13 +08:00
co-authored by Cursor multica-agent
parent c8a9376adb
commit 3e828b346c
24 changed files with 1011 additions and 160 deletions
+3 -3
View File
@@ -283,9 +283,9 @@ class HeavenMarketContextMixin:
) -> 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 收盘快照
观势行业层只使用申万二级行业。外显优先使用已发布的 sw_daily
盘中及收盘过渡期使用免费申万实时行情;内核使用数据中枢或免费
实时成分行情。不再调用无权限的 rt_sw_k / rt_k
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
+5 -2
View File
@@ -23,6 +23,9 @@ class ChartDataError(RuntimeError):
pass
DAILY_CHART_LIMIT = 250
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
HIS_TRENDS_URL = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
@@ -64,7 +67,7 @@ class MarketChartClient:
except (IfindError, ChartDataError):
return self.fallback.stock_intraday(normalized)
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
def stock_daily(self, code: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
@@ -73,7 +76,7 @@ class MarketChartClient:
return hub_rows
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
def index_daily(self, identifier: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
+98 -19
View File
@@ -15,6 +15,7 @@ from backend.bootstrap.config import (
)
from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.data.providers.tushare_helpers import _moneyflow_payload, _optional_number
from backend.data.realtime import RealtimeAggregateError
from backend.features.market.backfill_history import (
DEFAULT_RECENT_TRADING_DAYS,
@@ -27,7 +28,7 @@ from backend.features.market.backfill_history import (
select_open_trade_dates,
select_open_trade_dates_in_range,
)
from backend.features.market.charts import ChartDataError
from backend.features.market.charts import ChartDataError, DAILY_CHART_LIMIT
from backend.features.market.insights import MarketInsightsService
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
@@ -677,7 +678,7 @@ class MarketServiceMixin:
"index_daily",
{
"ts_code": basic["id"],
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
"start_date": (end - timedelta(days=400)).strftime("%Y%m%d"),
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
@@ -693,10 +694,10 @@ class MarketServiceMixin:
"change": float(row.get("pct_chg") or 0),
"volume": float(row.get("vol") or 0),
}
for row in rows[-90:]
for row in rows[-DAILY_CHART_LIMIT:]
]
try:
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, DAILY_CHART_LIMIT)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
@@ -804,7 +805,7 @@ class MarketServiceMixin:
result = copy.deepcopy(payload)
now = datetime.now().astimezone()
try:
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
result["prices"] = self.chart_data.stock_daily(code, requested_date, DAILY_CHART_LIMIT)
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
except (AttributeError, ChartDataError):
pass
@@ -837,7 +838,7 @@ class MarketServiceMixin:
**(result.get("meta") or {}),
"notice": TODAY_DAILY_UNAVAILABLE_NOTICE,
}
return self._enrich_stock_detail(result)
return self._enrich_stock_detail(result, requested_date)
@staticmethod
def _sanitize_stock_detail_prices(
@@ -1012,7 +1013,7 @@ class MarketServiceMixin:
else:
quote_date = str(row.get("quote_date") or today)
quote_time = f"{quote_date[:4]}-{quote_date[4:6]}-{quote_date[6:]}"
return {
quote = {
"name": str(row.get("name") or name or "--"),
"sector": sector,
"price": price,
@@ -1025,6 +1026,10 @@ class MarketServiceMixin:
"turnover_rate": float(row.get("turnover_rate") or 0),
"quote_time": quote_time,
}
flow = _moneyflow_payload(row)
if flow.get("available"):
quote["moneyflow"] = flow
return quote
def _intraday_realtime_stock_quote(
self, code: str, today: str, payload: dict[str, Any]
@@ -1100,19 +1105,24 @@ class MarketServiceMixin:
prices[-1] = realtime_bar
else:
prices.append(realtime_bar)
payload["prices"] = prices[-90:]
payload["prices"] = prices[-DAILY_CHART_LIMIT:]
stock = dict(payload.get("stock") or {})
stock.update(
{
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
"turnover_rate": quote["turnover_rate"],
}
)
updates = {
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
}
quote_turnover = _optional_number(quote.get("turnover_rate"))
if quote_turnover:
updates["turnover_rate"] = quote_turnover
stock.update(updates)
payload["stock"] = stock
quote_flow = quote.get("moneyflow")
current_flow = payload.get("moneyflow") or {}
if isinstance(quote_flow, dict) and quote_flow.get("available") and not current_flow.get("available"):
payload["moneyflow"] = quote_flow
payload["meta"] = {
**(payload.get("meta") or {}),
"trade_date": display_date,
@@ -1403,10 +1413,40 @@ class MarketServiceMixin:
return item["name"], item["sector"] or "其他"
return "--", "其他"
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
def _enrich_stock_detail(
self, payload: dict[str, Any], trade_date: str = ""
) -> dict[str, Any]:
result = dict(payload)
stock = dict(payload.get("stock") or {})
code = str(stock.get("code") or "")
compact_date = normalize_date(
str((payload.get("meta") or {}).get("trade_date") or trade_date)
)
board = self._limit_event_for_stock(code, compact_date)
if board:
if not stock.get("first_time") or stock.get("first_time") == "--":
stock["first_time"] = board.get("first_time") or "--"
if not stock.get("last_time") or stock.get("last_time") == "--":
stock["last_time"] = board.get("last_time") or "--"
if not stock.get("open_times"):
stock["open_times"] = board.get("open_times") or 0
if _optional_number(stock.get("seal_amount_million")) is None:
stock["seal_amount_million"] = board.get("seal_amount_million")
if not _optional_number(stock.get("turnover_rate")) and _optional_number(board.get("turnover_rate")):
stock["turnover_rate"] = board.get("turnover_rate")
flow = result.get("moneyflow") or {}
if not flow.get("available"):
live_flow = self._live_moneyflow_for_stock(code, compact_date)
if live_flow.get("available"):
result["moneyflow"] = live_flow
else:
result["moneyflow"] = {
"available": False,
"net_million": None,
"large_million": None,
"medium_million": None,
"small_million": None,
}
watched = {
item["code"]: item
for item in self.database.list_watchlist(self.current_user_id)
@@ -1416,6 +1456,45 @@ class MarketServiceMixin:
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
return result
def _limit_event_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
if not code or not trade_date:
return {}
ts_code = tushare_code(code)
client = self._tushare_client() if self.configured else None
rows: list[dict[str, Any]] = []
if client is not None:
try:
rows = client._load_limit_type(trade_date, "U") + client._load_limit_type(trade_date, "Z")
except Exception:
rows = []
if not rows:
try:
rows = list((client._free_board_map(trade_date) or {}).values())
except Exception:
rows = []
match = next((row for row in rows if str(row.get("ts_code") or "") == ts_code), None)
if not match:
return {}
fd = _optional_number(match.get("fd_amount"))
return {
"first_time": match.get("first_time") or "--",
"last_time": match.get("last_time") or "--",
"open_times": match.get("open_times") or 0,
"seal_amount_million": None if fd is None else round(fd / 10000, 0),
"turnover_rate": _optional_number(match.get("turnover_ratio")),
}
def _live_moneyflow_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
aggregator = getattr(self, "realtime_aggregator", None)
loader = getattr(aggregator, "eastmoney_stock_quote", None) if aggregator else None
if not callable(loader) or not code:
return _moneyflow_payload(None)
try:
quote = loader(tushare_code(code), expected_date=trade_date)
except Exception:
return _moneyflow_payload(None)
return _moneyflow_payload(quote)
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
result = dict(dashboard)
result["meta"] = {