from __future__ import annotations import re from datetime import datetime, time as dt_time from typing import Any from backend.bootstrap.config import normalize_date, validate_stock_code from backend.data.providers.ifind_client import IfindError class PoolServiceMixin: def save_reason(self, trade_date: str, code: str, reason: str) -> None: normalized_date = normalize_date(trade_date) code = validate_stock_code(code) reason = reason.strip() if not reason or len(reason) > 200: raise ValueError("涨停原因应为 1 至 200 个字符。") self.database.save_reason_override(normalized_date, code, reason) def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]: trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "") enrichment = self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date) if enrichment: self._merge_ifind_event_enrichment(dashboard, enrichment) else: self._schedule_ifind_event_enrichment(trade_date) overrides = self.database.reason_overrides(trade_date) if not overrides: return dashboard for key in ("limits", "broken", "down_limits"): for row in dashboard.get(key) or []: if row.get("code") in overrides: row["reason"] = overrides[row["code"]] row["reason_source"] = "manual" return dashboard def _schedule_ifind_event_enrichment(self, trade_date: str) -> None: ifind = getattr(self, "ifind", None) if not ifind or not ifind.configured or not re.fullmatch(r"\d{8}", trade_date): return now = datetime.now().astimezone() if trade_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < dt_time(15, 0): return self.jobs.submit( "market.ifind-event-enrichment", f"{trade_date}:v1", lambda: self._refresh_ifind_event_enrichment(trade_date), {"trade_date": trade_date, "trigger": "dashboard-enrichment"}, ) def _refresh_ifind_event_enrichment(self, trade_date: str) -> None: if not self._ifind_event_lock.acquire(blocking=False): return try: if self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date): return ifind = getattr(self, "ifind", None) if not ifind or not ifind.configured: return current = datetime.strptime(trade_date, "%Y%m%d") display_date = f"{current.year}年{current.month}月{current.day}日" requests = { "limits": ( f"{display_date}涨停股票,股票代码、股票简称、涨停原因、" "首次涨停时间、最终涨停时间、开板次数" ), "broken": ( f"{display_date}曾涨停但收盘未涨停的股票,股票代码、股票简称、" "涨停原因、首次涨停时间、开板次数" ), "down_limits": ( f"{display_date}跌停股票,股票代码、股票简称、跌停原因" ), } result: dict[str, Any] = { "trade_date": trade_date, "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "limits": {}, "broken": {}, "down_limits": {}, "partial": False, } for kind, query in requests.items(): try: rows = ifind.wencai(query, "stock", cache_ttl=900) except IfindError: result["partial"] = True continue for raw in rows: code = self._ifind_row_code(raw) if not code: continue reason_tokens = ( ("跌停原因", "风险线索", "原因") if kind == "down_limits" else ("涨停原因类别", "涨停原因", "触板逻辑", "原因") ) reason = str(self._ifind_field(raw, reason_tokens) or "").strip() first_time = self._normalize_ifind_event_time( self._ifind_field(raw, ("首次涨停时间", "首次触板时间", "首次封板时间")) ) last_time = self._normalize_ifind_event_time( self._ifind_field(raw, ("最终涨停时间", "最后涨停时间", "最后封板时间")) ) open_times = self._ifind_field(raw, ("开板次数", "打开涨停次数")) try: open_count = max(0, int(float(open_times))) if open_times not in (None, "") else None except (TypeError, ValueError): open_count = None result[kind][code] = { "reason": reason, "first_time": first_time, "last_time": last_time, "open_times": open_count, } if any(result[kind] for kind in ("limits", "broken", "down_limits")): self.database.save_data_snapshot( "ifind_event_enrichment_v1", trade_date, "ifind", result ) finally: self._ifind_event_lock.release() @staticmethod def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: for key, value in row.items(): label = str(key or "") if any(token.casefold() == label.casefold() for token in tokens): return value for key, value in row.items(): label = str(key or "") if any(token in label for token in tokens): return value return None @classmethod def _ifind_row_code(cls, row: dict[str, Any]) -> str: value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode")) match = re.search(r"(? str: text = str(value or "").strip() match = re.search(r"(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?:$|\s)", text) if not match: match = re.search(r"(? None: for kind in ("limits", "broken", "down_limits"): records = enrichment.get(kind) or {} for row in dashboard.get(kind) or []: event = records.get(str(row.get("code") or "")) or {} reason = str(event.get("reason") or "").strip() if reason: row["reason"] = reason row["reason_source"] = "market_event" if event.get("first_time"): row["first_time"] = event["first_time"] if event.get("last_time"): row["last_time"] = event["last_time"] if event.get("open_times") is not None: row["open_times"] = event["open_times"]