from __future__ import annotations from datetime import datetime import pandas as pd from futu import AuType, KLType, Market, OpenQuoteContext, RET_OK, SecurityType from market.provider_base import MarketDataProvider class FutuProvider(MarketDataProvider): def __init__( self, channel: str, host: str = "127.0.0.1", port: int = 11111, is_encrypt: bool | None = None, market: str = "", ) -> None: self._channel = channel.strip().lower() self._host = host self._port = int(port) self._is_encrypt = is_encrypt self._market = (market or self._channel).strip().lower() self._ctx = OpenQuoteContext(host=self._host, port=self._port, is_encrypt=self._is_encrypt) self._basicinfo_cache: pd.DataFrame | None = None @property def provider_name(self) -> str: return "futu" @property def channel(self) -> str: return self._channel def __del__(self) -> None: try: self._ctx.close() except Exception: pass def _require_market(self) -> Market: if self._market == "cn": return Market.SH if self._market == "hk": return Market.HK if self._market == "us": return Market.US raise RuntimeError(f"不支持的 FUTU_MARKET: {self._market},可选: cn/hk/us") def _normalize_code(self, query: str) -> str: q = query.strip().upper() if not q: return "" if "." in q: return q if self._market == "cn": if q.isdigit() and len(q) == 6: prefix = "SH" if q.startswith(("5", "6", "9")) else "SZ" return f"{prefix}.{q}" return q if self._market == "hk": if q.isdigit(): return f"HK.{q.zfill(5)}" return f"HK.{q}" if self._market == "us": return f"US.{q}" return q def _snapshot_to_unified(self, df: pd.DataFrame) -> pd.DataFrame: frame = df.copy() if "code" not in frame.columns: raise RuntimeError("Futu 快照返回缺少 code 字段") if "name" not in frame.columns: frame["name"] = "" frame["涨跌额"] = pd.to_numeric(frame.get("last_price"), errors="coerce") - pd.to_numeric( frame.get("prev_close_price"), errors="coerce" ) frame["市盈率-动态"] = frame.get("pe_ttm_ratio", frame.get("pe_ratio")) frame["市净率"] = frame.get("pb_ratio") frame["总市值"] = frame.get("total_market_val") frame["流通市值"] = frame.get("circular_market_val") frame["代码"] = frame["code"].astype(str) frame["名称"] = frame["name"] frame["最新价"] = frame.get("last_price") frame["涨跌幅"] = frame.get("change_rate") frame["成交量"] = frame.get("volume") frame["成交额"] = frame.get("turnover") frame["振幅"] = frame.get("amplitude") frame["最高"] = frame.get("high_price") frame["最低"] = frame.get("low_price") frame["今开"] = frame.get("open_price") frame["昨收"] = frame.get("prev_close_price") columns = [ "代码", "名称", "最新价", "涨跌幅", "涨跌额", "成交量", "成交额", "振幅", "最高", "最低", "今开", "昨收", "市盈率-动态", "市净率", "总市值", "流通市值", ] return frame[columns] def _get_snapshot(self, codes: list[str]) -> pd.DataFrame: ret, data = self._ctx.get_market_snapshot(codes) if ret != RET_OK: raise RuntimeError(f"Futu 获取快照失败: {data}") if data is None or data.empty: return pd.DataFrame() return self._snapshot_to_unified(data) def _load_basicinfo(self) -> pd.DataFrame: if self._basicinfo_cache is not None: return self._basicinfo_cache market = self._require_market() ret, data = self._ctx.get_stock_basicinfo(market=market, stock_type=SecurityType.STOCK) if ret != RET_OK: raise RuntimeError(f"Futu 获取股票基础信息失败: {data}") if data is None: self._basicinfo_cache = pd.DataFrame(columns=["code", "name"]) return self._basicinfo_cache frame = data.copy() frame["code"] = frame["code"].astype(str) frame["name"] = frame["name"].astype(str) self._basicinfo_cache = frame return frame def fetch_spot(self) -> pd.DataFrame: raise RuntimeError("Futu 不支持直接拉取全市场实时快照,请使用 search_spot") def search_spot(self, query: str, limit: int) -> pd.DataFrame: q = query.strip() if not q: return pd.DataFrame() code = self._normalize_code(q) if code: exact = self._get_snapshot([code]) if not exact.empty: return exact.head(limit) basic = self._load_basicinfo() q_lower = q.lower() code_col = basic["code"].astype(str) name_col = basic["name"].astype(str) mask = ( code_col.str.lower().eq(q_lower) | name_col.str.lower().eq(q_lower) | code_col.str.lower().str.startswith(q_lower) | name_col.str.lower().str.startswith(q_lower) | code_col.str.lower().str.contains(q_lower, na=False) | name_col.str.lower().str.contains(q_lower, na=False) ) candidates = basic.loc[mask, "code"].drop_duplicates().head(max(limit * 2, 20)).tolist() if not candidates: return pd.DataFrame() snap = self._get_snapshot(candidates) if snap.empty: return pd.DataFrame() return snap.head(limit) def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame: normalized = self._normalize_code(code) page_key = None frames: list[pd.DataFrame] = [] while True: ret, data, page_key = self._ctx.request_history_kline( normalized, start=start.strftime("%Y-%m-%d"), end=end.strftime("%Y-%m-%d"), ktype=KLType.K_DAY, autype=AuType.QFQ, max_count=1000, page_req_key=page_key, ) if ret != RET_OK: raise RuntimeError(f"Futu 获取历史 K 线失败: {data}") if data is not None and not data.empty: frames.append(data.copy()) if page_key is None: break if not frames: raise RuntimeError(f"未获取到 K 线数据: {normalized}") full = pd.concat(frames, ignore_index=True) frame = pd.DataFrame( { "date": pd.to_datetime(full["time_key"]), "close": pd.to_numeric(full["close"], errors="coerce"), "volume": pd.to_numeric(full["volume"], errors="coerce"), } ).dropna() if frame.empty: raise RuntimeError(f"K 线数据为空: {normalized}") return frame.sort_values("date").reset_index(drop=True)