daef609e8b
- QmtBridgeSource: provides 增加 index_daily,新增 fetch_index_daily 方法 - task_kline_index: 优先级改为 qmt_bridge → mairui → sina 三级降级 - task_kline_5min: 优先用 qmt_bridge,不可用时降级 mairui - task_kline_daily: 优先级加入 qmt_bridge(首位) - config: 新增 qmt_bridge_url 配置项 - registry: qmt_bridge 注册信息同步更新 - docs: 新增 DATASETS_AND_SOURCES.md,完整说明数据集与数据源依赖关系 - AGENTS.md: 同步更新指数数据源描述
236 lines
8.8 KiB
Python
236 lines
8.8 KiB
Python
"""QMT Bridge 数据源(本地行情桥 HTTP 接口)。
|
||
|
||
将 xtquant 行情 API 通过 HTTP 暴露的本地服务,提供日 K、5 分钟 K 线和指数日 K。
|
||
|
||
无需凭证(本地服务),超时短(局域网),不限速。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import urllib.request
|
||
from datetime import datetime
|
||
from typing import Any, Optional
|
||
|
||
import pandas as pd
|
||
|
||
from app.core.config import settings
|
||
from app.core.datasource.base import DataSource
|
||
from app.core.datasource.utils import code6_to_mairui, normalize_5min
|
||
from app.core.utils.logging import get_logger
|
||
|
||
logger = get_logger("qmt_bridge")
|
||
|
||
KLINE_FIELDS = "close,open,high,low,volume"
|
||
KLINE_5MIN_FIELDS = "close,open,high,low,volume,amount"
|
||
|
||
|
||
class QmtBridgeSource(DataSource):
|
||
key = "datasource_qmt_bridge"
|
||
name = "QMT Bridge(本地行情桥)"
|
||
provides = ["kline_daily", "kline_5min", "index_daily"]
|
||
requires_credential = False
|
||
credential_key = ""
|
||
|
||
def __init__(self) -> None:
|
||
self._base_url = (settings.qmt_bridge_url or "http://127.0.0.1:8610").rstrip("/")
|
||
|
||
# ── 健康检查 / 可用性 ─────────────────────────────────
|
||
|
||
def is_available(self) -> tuple[bool, str]:
|
||
try:
|
||
resp = self._get("/health")
|
||
if isinstance(resp, dict) and resp.get("status") == "ok":
|
||
return True, "就绪"
|
||
return False, f"bridge 返回异常: {resp}"
|
||
except Exception as e:
|
||
return False, f"bridge 不可达: {e}"
|
||
|
||
def health_check(self) -> dict[str, Any]:
|
||
try:
|
||
resp = self._get("/health")
|
||
if isinstance(resp, dict) and resp.get("status") == "ok":
|
||
return {"success": True, "message": f"bridge 连接正常"}
|
||
return {"success": False, "message": f"bridge 返回异常: {resp}"}
|
||
except Exception as e:
|
||
return {"success": False, "message": f"bridge 连接失败: {e}"}
|
||
|
||
# ── K 线 fetch ─────────────────────────────────────
|
||
|
||
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||
"""日 K 线。
|
||
|
||
Args:
|
||
code6: 6 位股票代码(如 "600519")
|
||
start: "YYYY-MM-DD"
|
||
end: "YYYY-MM-DD"
|
||
|
||
Returns:
|
||
DataFrame 列: trade_date, open, high, low, close, volume
|
||
"""
|
||
symbol = code6_to_mairui(code6)
|
||
params = {
|
||
"code": symbol,
|
||
"period": "1d",
|
||
"start": start.replace("-", ""),
|
||
"end": end.replace("-", ""),
|
||
"count": "-1",
|
||
"fields": KLINE_FIELDS,
|
||
}
|
||
data = self._get("/kline", params)
|
||
records = self._parse_kline_data(data, is_daily=True)
|
||
if not records:
|
||
return pd.DataFrame()
|
||
df = pd.DataFrame(records)
|
||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||
return self._normalize_kline(df)
|
||
|
||
def fetch_kline_5min(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||
"""5 分钟 K 线。
|
||
|
||
Args:
|
||
code6: 6 位股票代码(如 "600519")
|
||
start: "YYYY-MM-DD"
|
||
end: "YYYY-MM-DD"
|
||
|
||
Returns:
|
||
DataFrame 列: bar_time, open, high, low, close, volume, amount, turnover_rate
|
||
"""
|
||
symbol = code6_to_mairui(code6)
|
||
params = {
|
||
"code": symbol,
|
||
"period": "5m",
|
||
"start": start.replace("-", ""),
|
||
"end": end.replace("-", ""),
|
||
"count": "-1",
|
||
"fields": KLINE_5MIN_FIELDS,
|
||
}
|
||
data = self._get("/kline", params)
|
||
records = self._parse_kline_data(data, is_daily=False)
|
||
if not records:
|
||
return pd.DataFrame()
|
||
df = pd.DataFrame(records)
|
||
# 用 normalize_5min 标准化
|
||
return normalize_5min(df)
|
||
|
||
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
|
||
"""指数日 K 线。
|
||
|
||
Args:
|
||
index_code: 带交易所后缀的指数代码(如 "000300.SH")
|
||
start: "YYYY-MM-DD"
|
||
end: "YYYY-MM-DD"
|
||
|
||
Returns:
|
||
DataFrame 列: trade_date, open, high, low, close, volume
|
||
"""
|
||
params = {
|
||
"code": index_code,
|
||
"period": "1d",
|
||
"start": start.replace("-", ""),
|
||
"end": end.replace("-", ""),
|
||
"count": "-1",
|
||
"fields": KLINE_FIELDS,
|
||
}
|
||
data = self._get("/kline", params)
|
||
records = self._parse_kline_data(data, is_daily=True)
|
||
if not records:
|
||
return pd.DataFrame()
|
||
df = pd.DataFrame(records)
|
||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||
return self._normalize_kline(df)
|
||
|
||
# ── 内部方法 ───────────────────────────────────────
|
||
|
||
def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
|
||
"""GET 请求 bridge,返回解析后的 JSON。"""
|
||
url = f"{self._base_url}{path}"
|
||
if params:
|
||
qs = "&".join(f"{k}={v}" for k, v in params.items() if v)
|
||
url = f"{url}?{qs}"
|
||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
raw = resp.read().decode("utf-8")
|
||
return json.loads(raw)
|
||
except Exception as e:
|
||
logger.warning(f"[qmt_bridge] GET {path} 失败: {e}")
|
||
raise
|
||
|
||
def _parse_kline_data(
|
||
self, data: dict[str, Any], is_daily: bool
|
||
) -> list[dict[str, Any]]:
|
||
"""桥响应解析为统一记录列表。跳过全零记录(停牌/未交易)。"""
|
||
records = []
|
||
raw_list = data.get("data") if isinstance(data, dict) else None
|
||
if not isinstance(raw_list, list):
|
||
return records
|
||
for item in raw_list:
|
||
try:
|
||
t = str(item.get("time", "") or "")
|
||
if not t:
|
||
continue
|
||
# 跳过全零记录(停牌/未交易)
|
||
o = float(item.get("open") or 0)
|
||
h = float(item.get("high") or 0)
|
||
l = float(item.get("low") or 0)
|
||
c = float(item.get("close") or 0)
|
||
v = float(item.get("volume") or 0)
|
||
if o <= 0 and h <= 0 and l <= 0 and c <= 0 and v <= 0:
|
||
continue
|
||
if is_daily:
|
||
# 日 K: time = "YYYYMMDD"
|
||
if len(t) >= 8:
|
||
trade_date = f"{t[:4]}-{t[4:6]}-{t[6:8]}"
|
||
else:
|
||
continue
|
||
records.append({
|
||
"trade_date": trade_date,
|
||
"open": o,
|
||
"high": h,
|
||
"low": l,
|
||
"close": c,
|
||
"volume": v,
|
||
})
|
||
else:
|
||
# 5min K: time = "YYYYMMDDHHmmss"
|
||
if len(t) >= 14:
|
||
bar_time = f"{t[:4]}-{t[4:6]}-{t[6:8]} {t[8:10]}:{t[10:12]}:{t[12:14]}"
|
||
elif len(t) >= 8:
|
||
bar_time = f"{t[:4]}-{t[4:6]}-{t[6:8]} 00:00:00"
|
||
else:
|
||
continue
|
||
amt = float(item.get("amount") or 0)
|
||
records.append({
|
||
"bar_time": bar_time,
|
||
"open": o,
|
||
"high": h,
|
||
"low": l,
|
||
"close": c,
|
||
"volume": v,
|
||
"amount": amt,
|
||
"turnover_rate": 0.0, # bridge 不提供
|
||
})
|
||
except (KeyError, ValueError, TypeError):
|
||
continue
|
||
return records
|
||
|
||
@staticmethod
|
||
def _normalize_kline(df: pd.DataFrame) -> pd.DataFrame:
|
||
"""日 K 标准化:类型转换 + OHLC 校验。"""
|
||
if df.empty:
|
||
return df
|
||
for col in ["open", "high", "low", "close", "volume"]:
|
||
if col in df.columns:
|
||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||
df = df.dropna(subset=["trade_date", "open", "high", "low", "close"])
|
||
df["volume"] = df["volume"].fillna(0)
|
||
# OHLC 合理性检查
|
||
valid = (
|
||
(df["open"] > 0) & (df["high"] > 0) & (df["low"] > 0) & (df["close"] > 0)
|
||
& (df["volume"] >= 0)
|
||
& (df["high"] >= df[["open", "low", "close"]].max(axis=1) - 0.001)
|
||
& (df["low"] <= df[["open", "high", "close"]].min(axis=1) + 0.001)
|
||
)
|
||
df = df.loc[valid].sort_values("trade_date")
|
||
return df.drop_duplicates(subset=["trade_date"], keep="last").reset_index(drop=True)
|