Files
xiaobaifupan/app/backend/features/pools/service.py
T

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"]