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()