feat: 修复报错

This commit is contained in:
Daniel
2026-03-26 14:13:44 +08:00
commit b2223ec058
31 changed files with 17401 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
from market.providers.akshare_provider import AkshareCnProvider
from market.providers.cmes_provider import CmesCnProvider
__all__ = ["AkshareCnProvider", "CmesCnProvider"]
try:
from market.providers.futu_provider import FutuProvider
__all__.append("FutuProvider")
except Exception:
# Allow non-futu environments to keep using other providers.
pass

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
import akshare as ak
import pandas as pd
from market.provider_base import MarketDataProvider
class AkshareCnProvider(MarketDataProvider):
@property
def provider_name(self) -> str:
return "akshare"
@property
def channel(self) -> str:
return "cn"
def fetch_spot(self) -> pd.DataFrame:
df = ak.stock_zh_a_spot_em()
if df.empty:
raise RuntimeError("未获取到 A 股实时行情数据")
return df
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
q = query.strip().lower()
if not q:
return pd.DataFrame()
df = self.fetch_spot()
code = df["代码"].astype(str).str.lower()
name = df["名称"].astype(str).str.lower()
exact = df[(code == q) | (name == q)]
if not exact.empty:
return exact.head(limit)
starts = df[code.str.startswith(q) | name.str.startswith(q)]
if not starts.empty:
return starts.head(limit)
return df[code.str.contains(q, na=False) | name.str.contains(q, na=False)].head(limit)
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
hist = ak.stock_zh_a_hist(
symbol=code,
period="daily",
start_date=start.strftime("%Y%m%d"),
end_date=end.strftime("%Y%m%d"),
adjust="qfq",
)
if hist.empty:
raise RuntimeError(f"未获取到 K 线数据: {code}")
frame = pd.DataFrame(
{
"date": pd.to_datetime(hist["日期"]),
"close": pd.to_numeric(hist["收盘"], errors="coerce"),
"volume": pd.to_numeric(hist["成交量"], errors="coerce"),
}
).dropna()
return frame.sort_values("date").reset_index(drop=True)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import importlib
import os
from datetime import datetime
import pandas as pd
from market.provider_base import MarketDataProvider
class CmesCnProvider(MarketDataProvider):
def __init__(self, token: str | None = None) -> None:
self._token = token or os.getenv("CMES_TOKEN", "").strip()
self._module = importlib.import_module("cmesdata")
self._login_once()
@property
def provider_name(self) -> str:
return "cmesdata"
@property
def channel(self) -> str:
return "cn"
def _login_once(self) -> None:
if not self._token:
raise RuntimeError("CMES_TOKEN 未配置,无法使用 cmesdata 通道")
self._module.login(self._token)
@staticmethod
def _to_prefixed_code(code: str) -> str:
raw = code.strip().upper().replace(".", "")
if raw.startswith("SH") or raw.startswith("SZ"):
return f"{raw[:2]}.{raw[2:]}"
if raw.isdigit() and len(raw) == 6:
if raw.startswith(("6", "9")):
return f"SH.{raw}"
return f"SZ.{raw}"
raise RuntimeError("cmesdata 通道仅支持 6 位 A 股代码或 SH./SZ. 前缀代码")
def fetch_spot(self) -> pd.DataFrame:
raise RuntimeError("cmesdata 不支持全市场快照拉取,请通过精确代码查询")
def search_spot(self, query: str, limit: int) -> pd.DataFrame:
_ = limit
code = self._to_prefixed_code(query)
df = self._module.get_real_hq([code])
if df is None or df.empty:
raise RuntimeError(f"未获取到实时行情: {code}")
return df
def fetch_daily_kline(self, code: str, start: datetime, end: datetime) -> pd.DataFrame:
prefixed = self._to_prefixed_code(code)
df = self._module.get_history_data(
prefixed,
start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"),
"D",
)
if df is None or df.empty:
raise RuntimeError(f"未获取到历史 K 线: {prefixed}")
frame = pd.DataFrame(
{
"date": pd.to_datetime(df["时间"]),
"close": pd.to_numeric(df["收盘价"], errors="coerce"),
"volume": pd.to_numeric(df["成交量"], errors="coerce"),
}
).dropna()
return frame.sort_values("date").reset_index(drop=True)

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