migration: preserve sentiment and pools slice
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Limit-up, broken-board, limit-down and prior-limit pool feature."""
|
||||
|
||||
from .repository import PoolRepositoryMixin
|
||||
from .service import PoolServiceMixin
|
||||
|
||||
__all__ = ["PoolRepositoryMixin", "PoolServiceMixin"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PoolRepositoryMixin:
|
||||
def save_reason_override(self, trade_date: str, code: str, reason: str) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reason_overrides (trade_date, code, reason, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(trade_date, code) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(trade_date, code, reason, now),
|
||||
)
|
||||
|
||||
def reason_overrides(self, trade_date: str) -> dict[str, str]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT code, reason FROM reason_overrides WHERE trade_date = ?",
|
||||
(trade_date,),
|
||||
).fetchall()
|
||||
return {row["code"]: row["reason"] for row in rows}
|
||||
@@ -0,0 +1,150 @@
|
||||
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 _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"]
|
||||
Reference in New Issue
Block a user