175 lines
7.8 KiB
Python
175 lines
7.8 KiB
Python
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"(?<!\d)(\d{6})(?!\d)", str(value or ""))
|
|
if match:
|
|
return match.group(1)
|
|
for value in row.values():
|
|
match = re.search(r"(?<!\d)(\d{6})\.(?:SH|SZ|BJ)(?![A-Z])", str(value or ""), re.I)
|
|
if match:
|
|
return match.group(1)
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _normalize_ifind_event_time(value: Any) -> 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"(?<!\d)(\d{6})(?!\d)", text)
|
|
if match:
|
|
compact = match.group(1)
|
|
return f"{compact[:2]}:{compact[2:4]}:{compact[4:]}"
|
|
return ""
|
|
parts = match.group(1).split(":")
|
|
return ":".join(part.zfill(2) for part in parts)
|
|
|
|
@staticmethod
|
|
def _merge_ifind_event_enrichment(
|
|
dashboard: dict[str, Any], enrichment: dict[str, Any]
|
|
) -> 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"]
|