391 lines
16 KiB
Python
391 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from app.clients.ths_etf_client import ThsEtfClient
|
|
from app.repositories.monitoring_repository import MonitoringRepository
|
|
from app.services.email_notification_service import email_notification_service
|
|
|
|
|
|
ETF_GROUPS = {
|
|
"broad": [
|
|
{"code": "510050", "label": "上证50ETF", "market": "17"},
|
|
{"code": "510300", "label": "沪深300ETF", "market": "17"},
|
|
{"code": "510500", "label": "中证500ETF", "market": "17"},
|
|
{"code": "588000", "label": "科创50ETF", "market": "17"},
|
|
{"code": "159845", "label": "中证1000ETF", "market": "33"},
|
|
{"code": "159532", "label": "中证2000ETF", "market": "33"},
|
|
],
|
|
"sector": [
|
|
{"code": "512880", "label": "证券ETF", "market": "17"},
|
|
{"code": "512800", "label": "银行ETF", "market": "17"},
|
|
{"code": "159819", "label": "人工智能ETF", "market": "33"},
|
|
{"code": "513180", "label": "恒生科技ETF", "market": "17"},
|
|
{"code": "512480", "label": "半导体ETF", "market": "17"},
|
|
],
|
|
}
|
|
|
|
|
|
class EtfMonitorService:
|
|
def __init__(self) -> None:
|
|
self.client = ThsEtfClient()
|
|
self.repository = MonitoringRepository()
|
|
self.tz = ZoneInfo("Asia/Shanghai")
|
|
|
|
def _now(self) -> datetime:
|
|
return datetime.now(self.tz)
|
|
|
|
def _today(self) -> str:
|
|
return self._now().date().isoformat()
|
|
|
|
@staticmethod
|
|
def _safe_float(value: str | float | int | None) -> float | None:
|
|
if value in (None, "", "-"):
|
|
return None
|
|
return float(value)
|
|
|
|
@staticmethod
|
|
def _safe_int(value: str | float | int | None) -> int | None:
|
|
if value in (None, "", "-"):
|
|
return None
|
|
return int(float(value))
|
|
|
|
@staticmethod
|
|
def _detail_url(code: str) -> str:
|
|
return f"https://fund.10jqka.com.cn/{code}/"
|
|
|
|
@staticmethod
|
|
def _source_url(code: str) -> str:
|
|
return f"https://basic.10jqka.com.cn/{code}/"
|
|
|
|
def _normalize_turnover(self, value: str | float | int | None) -> float | None:
|
|
parsed = self._safe_float(value)
|
|
if parsed is None:
|
|
return None
|
|
return round(parsed / 100000000, 4)
|
|
|
|
def _parse_intraday_points(self, raw: dict) -> list[dict]:
|
|
raw_data = raw.get("data") or ""
|
|
if not raw_data:
|
|
return []
|
|
points: list[dict] = []
|
|
trade_date = raw.get("date")
|
|
for item in raw_data.split(";"):
|
|
parts = item.split(",")
|
|
if len(parts) < 5:
|
|
continue
|
|
hhmm = parts[0]
|
|
points.append(
|
|
{
|
|
"timestamp": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}T{hhmm[:2]}:{hhmm[2:]}:00+08:00",
|
|
"price": self._safe_float(parts[1]),
|
|
"volume": self._safe_int(parts[2]),
|
|
"avg_price": self._safe_float(parts[3]),
|
|
"turnover_amount": self._safe_int(parts[4]),
|
|
}
|
|
)
|
|
return points
|
|
|
|
@staticmethod
|
|
def _compute_change(points: list[dict], minutes: int) -> float | None:
|
|
if len(points) <= minutes:
|
|
return None
|
|
latest = points[-1].get("price")
|
|
previous = points[-1 - minutes].get("price")
|
|
if latest in (None, 0) or previous in (None, 0):
|
|
return None
|
|
return round((float(latest) / float(previous) - 1) * 100, 4)
|
|
|
|
def _build_record(self, definition: dict) -> tuple[dict, dict]:
|
|
code = definition["code"]
|
|
market = definition["market"]
|
|
profile_payload = self.client.fetch_profile(code)
|
|
quote_payload = self.client.fetch_today_quote(market, code)
|
|
intraday_payload = self.client.fetch_intraday_time(market, code)
|
|
|
|
profile = profile_payload.get("data") or {}
|
|
points = self._parse_intraday_points(intraday_payload)
|
|
latest_point = points[-1] if points else {}
|
|
previous_close = self._safe_float(intraday_payload.get("pre")) or self._safe_float(profile.get("net"))
|
|
latest_price = self._safe_float(quote_payload.get("11")) or latest_point.get("price")
|
|
if latest_price is None:
|
|
latest_price = previous_close
|
|
|
|
change_percent = None
|
|
if latest_price not in (None, 0) and previous_close not in (None, 0):
|
|
change_percent = round((float(latest_price) / float(previous_close) - 1) * 100, 4)
|
|
|
|
updated_at = self._now().isoformat(timespec="seconds")
|
|
snapshot_time = None
|
|
if points:
|
|
snapshot_time = points[-1]["timestamp"]
|
|
elif quote_payload.get("dt"):
|
|
dt = str(quote_payload["dt"]).zfill(4)
|
|
snapshot_time = f"{self._today()}T{dt[:2]}:{dt[2:]}:00+08:00"
|
|
|
|
record = {
|
|
"trade_date": self._today(),
|
|
"code": code,
|
|
"name": definition["label"],
|
|
"fund_name": profile.get("name") or definition["label"],
|
|
"detail_url": self._detail_url(code),
|
|
"source_url": self._source_url(code),
|
|
"latest_price": latest_price,
|
|
"change_percent": change_percent,
|
|
"change_amount": round(float(latest_price) - float(previous_close), 4)
|
|
if latest_price is not None and previous_close is not None
|
|
else None,
|
|
"previous_close": previous_close,
|
|
"open_price": self._safe_float(quote_payload.get("7")),
|
|
"high_price": self._safe_float(quote_payload.get("8")),
|
|
"low_price": self._safe_float(quote_payload.get("9")),
|
|
"volume": self._safe_int(quote_payload.get("13")),
|
|
"turnover_amount": self._normalize_turnover(quote_payload.get("19")),
|
|
"turnover_rate": self._safe_float(quote_payload.get("1968584")),
|
|
"change_percent_1m": self._compute_change(points, 1),
|
|
"change_percent_3m": self._compute_change(points, 3),
|
|
"change_percent_4m": self._compute_change(points, 4),
|
|
"updated_at": updated_at,
|
|
"snapshot_time": snapshot_time,
|
|
"source_name": "同花顺",
|
|
"precision": "realtime_exact",
|
|
"is_trading": bool(intraday_payload.get("isTrading")),
|
|
}
|
|
raw_payload = {
|
|
"profile": profile_payload,
|
|
"quote": quote_payload,
|
|
"intraday": intraday_payload,
|
|
}
|
|
return record, raw_payload
|
|
|
|
def _save_daily_records(self, group: str, records: list[dict], *, precision: str) -> None:
|
|
payload = {
|
|
"trade_date": self._today(),
|
|
"updated_at": self._now().isoformat(timespec="seconds"),
|
|
"source_name": "同花顺",
|
|
"source_url": "https://fund.10jqka.com.cn/",
|
|
"precision": precision,
|
|
"records": sorted(records, key=lambda item: item["code"]),
|
|
}
|
|
self.repository.save_document(f"etf_{group}_daily", payload["trade_date"], payload, sort_value=payload["trade_date"])
|
|
|
|
def _send_alert_if_needed(self, group: str, record: dict) -> None:
|
|
config = self.repository.get_system_config()
|
|
if not config.get("email_enabled"):
|
|
return
|
|
|
|
threshold = float(config.get("etf_3min_change_alert_percent", 0.8))
|
|
cooldown_minutes = int(config.get("etf_alert_cooldown_minutes", 10))
|
|
change_3m = record.get("change_percent_3m")
|
|
if change_3m is None or abs(change_3m) < threshold:
|
|
return
|
|
|
|
alert_state = self.repository.get_document("etf_alert_state", self._today(), {})
|
|
record_key = f"{group}:{record['code']}:{'up' if change_3m > 0 else 'down'}"
|
|
last_sent_at = alert_state.get(record_key)
|
|
now = self._now()
|
|
if last_sent_at:
|
|
elapsed = now - datetime.fromisoformat(last_sent_at)
|
|
if elapsed.total_seconds() < cooldown_minutes * 60:
|
|
return
|
|
|
|
direction = "上涨" if change_3m > 0 else "下跌"
|
|
subject = f"[ETF监控] {record['name']} 3分钟{direction} {change_3m:+.2f}%"
|
|
body = "\n".join(
|
|
[
|
|
"ETF 异动提醒",
|
|
"",
|
|
f"分组: {'宽基ETF' if group == 'broad' else '板块ETF'}",
|
|
f"名称: {record['name']}",
|
|
f"代码: {record['code']}",
|
|
f"最新价: {record['latest_price'] or '-'}",
|
|
f"当日涨跌幅: {record['change_percent'] or '-'}%",
|
|
f"3分钟涨跌幅: {change_3m:+.2f}%",
|
|
f"4分钟涨跌幅: {record.get('change_percent_4m') if record.get('change_percent_4m') is not None else '-'}%",
|
|
f"成交额(亿元): {record['turnover_amount'] or '-'}",
|
|
f"时间: {record.get('snapshot_time') or record.get('updated_at') or '-'}",
|
|
"",
|
|
f"详情页: {record['detail_url']}",
|
|
]
|
|
)
|
|
try:
|
|
email_notification_service.send(
|
|
smtp_host=config.get("smtp_host", ""),
|
|
smtp_port=int(config.get("smtp_port", 465)),
|
|
smtp_username=config.get("smtp_username", ""),
|
|
smtp_password=config.get("smtp_password", ""),
|
|
sender_email=config.get("sender_email", ""),
|
|
recipients=config.get("recipients", []),
|
|
subject=subject,
|
|
text_body=body,
|
|
)
|
|
push_status = "sent"
|
|
error_message = None
|
|
except Exception as exc:
|
|
push_status = "failed"
|
|
error_message = str(exc)
|
|
|
|
self.repository.append_push_record(
|
|
{
|
|
"id": f"push-{uuid4().hex[:12]}",
|
|
"triggered_at": now.isoformat(timespec="seconds"),
|
|
"push_type": "email",
|
|
"rule_code": "etf_3min_change",
|
|
"trigger_value_hkd_billion": None,
|
|
"description": f"{record['name']} 3分钟{direction}触发 ETF 监控阈值",
|
|
"email_subject": subject,
|
|
"email_summary": f"{record['name']} 3分钟涨跌幅 {change_3m:+.2f}%",
|
|
"status": push_status,
|
|
"error_message": error_message,
|
|
}
|
|
)
|
|
alert_state[record_key] = now.isoformat(timespec="seconds")
|
|
self.repository.save_document("etf_alert_state", self._today(), alert_state, sort_value=self._today())
|
|
|
|
def sync_group_realtime(self, group: str) -> dict:
|
|
records: list[dict] = []
|
|
raw_payloads: dict[str, dict] = {}
|
|
for definition in ETF_GROUPS[group]:
|
|
record, raw_payload = self._build_record(definition)
|
|
records.append(record)
|
|
raw_payloads[definition["code"]] = raw_payload
|
|
self._send_alert_if_needed(group, record)
|
|
|
|
payload = {
|
|
"trade_date": self._today(),
|
|
"updated_at": self._now().isoformat(timespec="seconds"),
|
|
"source_name": "同花顺",
|
|
"source_url": "https://fund.10jqka.com.cn/",
|
|
"precision": "realtime_exact",
|
|
"group": group,
|
|
"records": records,
|
|
}
|
|
self.repository.save_document(f"etf_{group}_realtime", payload["trade_date"], payload, sort_value=payload["trade_date"])
|
|
self.repository.save_document(f"etf_{group}_latest_success", "default", payload, sort_value=payload["trade_date"])
|
|
self.repository.save_raw_payload(f"etf_{group}_realtime_{payload['trade_date']}", raw_payloads)
|
|
self._save_daily_records(group, records, precision="realtime_exact")
|
|
return payload
|
|
|
|
def _parse_history_rows(self, definition: dict) -> list[dict]:
|
|
code = definition["code"]
|
|
market = definition["market"]
|
|
payload = self.client.fetch_history(market, code)
|
|
raw = payload.get(f"{market}_{code}", {})
|
|
rows = raw.get("data") or ""
|
|
if not rows:
|
|
return []
|
|
records: list[dict] = []
|
|
for row in rows.split(";"):
|
|
parts = row.split(",")
|
|
if len(parts) < 8:
|
|
continue
|
|
trade_date = f"{parts[0][:4]}-{parts[0][4:6]}-{parts[0][6:8]}"
|
|
if trade_date < "2026-01-01":
|
|
continue
|
|
close_price = self._safe_float(parts[4])
|
|
previous_close = self._safe_float(parts[1])
|
|
records.append(
|
|
{
|
|
"trade_date": trade_date,
|
|
"code": code,
|
|
"name": definition["label"],
|
|
"fund_name": raw.get("name") or definition["label"],
|
|
"detail_url": self._detail_url(code),
|
|
"source_url": self._source_url(code),
|
|
"latest_price": close_price,
|
|
"change_percent": round((float(close_price) / float(previous_close) - 1) * 100, 4)
|
|
if close_price is not None and previous_close not in (None, 0)
|
|
else None,
|
|
"change_amount": round(float(close_price) - float(previous_close), 4)
|
|
if close_price is not None and previous_close is not None
|
|
else None,
|
|
"previous_close": previous_close,
|
|
"open_price": self._safe_float(parts[1]),
|
|
"high_price": self._safe_float(parts[2]),
|
|
"low_price": self._safe_float(parts[3]),
|
|
"volume": self._safe_int(parts[5]),
|
|
"turnover_amount": self._normalize_turnover(parts[6]),
|
|
"turnover_rate": self._safe_float(parts[7]),
|
|
"change_percent_1m": None,
|
|
"change_percent_3m": None,
|
|
"change_percent_4m": None,
|
|
"updated_at": self._now().isoformat(timespec="seconds"),
|
|
"snapshot_time": None,
|
|
"source_name": "同花顺",
|
|
"precision": "historical_exact",
|
|
"is_trading": False,
|
|
}
|
|
)
|
|
return records
|
|
|
|
def backfill_group_daily(self, group: str) -> dict:
|
|
by_date: dict[str, list[dict]] = {}
|
|
for definition in ETF_GROUPS[group]:
|
|
for record in self._parse_history_rows(definition):
|
|
by_date.setdefault(record["trade_date"], []).append(record)
|
|
|
|
for trade_date, records in by_date.items():
|
|
payload = {
|
|
"trade_date": trade_date,
|
|
"updated_at": self._now().isoformat(timespec="seconds"),
|
|
"source_name": "同花顺",
|
|
"source_url": "https://fund.10jqka.com.cn/",
|
|
"precision": "historical_exact",
|
|
"records": sorted(records, key=lambda item: item["code"]),
|
|
}
|
|
self.repository.save_document(f"etf_{group}_daily", trade_date, payload, sort_value=trade_date)
|
|
|
|
meta = {
|
|
"group": group,
|
|
"updated_at": self._now().isoformat(timespec="seconds"),
|
|
"trade_day_count": len(by_date),
|
|
"etf_count": len(ETF_GROUPS[group]),
|
|
"start_date": "2026-01-01",
|
|
}
|
|
self.repository.save_document("etf_history_meta", group, meta, sort_value=meta["updated_at"])
|
|
return meta
|
|
|
|
def ensure_history_backfilled(self) -> None:
|
|
for group in ETF_GROUPS:
|
|
meta = self.repository.get_document("etf_history_meta", group, {})
|
|
if meta.get("start_date") == "2026-01-01" and meta.get("trade_day_count"):
|
|
continue
|
|
self.backfill_group_daily(group)
|
|
|
|
def get_group_realtime(self, group: str) -> dict:
|
|
payload = self.repository.get_document(f"etf_{group}_realtime", self._today(), {})
|
|
if payload:
|
|
return payload
|
|
fallback = self.repository.get_document(f"etf_{group}_latest_success", "default", {})
|
|
if fallback:
|
|
return fallback
|
|
return {
|
|
"trade_date": self._today(),
|
|
"updated_at": None,
|
|
"source_name": "同花顺",
|
|
"source_url": "https://fund.10jqka.com.cn/",
|
|
"precision": "unavailable",
|
|
"group": group,
|
|
"records": [],
|
|
}
|
|
|
|
def get_group_daily(self, group: str, trade_date: str | None = None) -> dict:
|
|
target_date = trade_date or self._today()
|
|
payload = self.repository.get_document(f"etf_{group}_daily", target_date, {})
|
|
if payload:
|
|
return payload
|
|
return {
|
|
"trade_date": target_date,
|
|
"updated_at": None,
|
|
"source_name": "同花顺",
|
|
"source_url": "https://fund.10jqka.com.cn/",
|
|
"precision": "unavailable",
|
|
"group": group,
|
|
"records": [],
|
|
}
|
|
|
|
|
|
etf_monitor_service = EtfMonitorService()
|