feat: 修复报错
This commit is contained in:
207
python-app/app/market/providers/futu_provider.py
Normal file
207
python-app/app/market/providers/futu_provider.py
Normal file
@@ -0,0 +1,207 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user