实现 sidecar 模块

This commit is contained in:
cheney
2026-06-23 10:37:31 +08:00
parent ca5dd30eed
commit 31d28d8683
20 changed files with 1434 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""数据源适配器包。"""
+27
View File
@@ -0,0 +1,27 @@
"""数据源适配器公共协议。"""
from __future__ import annotations
from typing import List, Protocol
from sidecar.models import KlineBar, Snapshot
class MarketDataSource(Protocol):
"""行情数据源协议。"""
def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
"""
功能说明:从远端数据源获取 K 线。
参数说明:symbol 为股票代码,period 为周期,limit 为最大条数。
返回值说明:返回统一 KlineBar 列表。
注意事项:不支持 K 线的数据源可抛出 NotImplementedError。
"""
def fetch_snapshot(self, symbol: str) -> Snapshot:
"""
功能说明:从远端数据源获取实时快照。
参数说明:symbol 为股票代码。
返回值说明:返回统一 Snapshot。
注意事项:数据源字段缺失时用 None 表示。
"""
+145
View File
@@ -0,0 +1,145 @@
"""mootdx 数据源适配器。"""
from __future__ import annotations
from typing import Any, Dict, List
from sidecar.models import KlineBar, Snapshot, parse_date, parse_float
from sidecar.symbols import mootdx_market, normalize_symbol, split_symbol
PERIOD_CATEGORY = {
"day": 4,
"week": 5,
"month": 6,
"1m": 7,
"5m": 8,
"15m": 9,
"30m": 10,
"60m": 11,
}
class MootdxSource:
"""mootdx 行情适配器。"""
def __init__(self) -> None:
"""
功能说明:创建 mootdx 数据源。
参数说明:无。
返回值说明:无返回值。
注意事项:mootdx 依赖在初始化时导入,未安装会抛出清晰异常。
"""
try:
from mootdx.quotes import Quotes
except ImportError as exc:
raise RuntimeError("请先安装 mootdx:pip install mootdx") from exc
self.client = Quotes.factory(market="std")
def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
"""
功能说明:通过 mootdx 获取 K 线数据。
参数说明:symbol 为股票代码,period 为周期,limit 为最大条数。
返回值说明:返回统一 KlineBar 列表。
注意事项:period 支持 day/week/month/1m/5m/15m/30m/60m。
"""
normalized = normalize_symbol(symbol)
category = PERIOD_CATEGORY.get(period)
if category is None:
raise ValueError("不支持的 K 线周期: %s" % period)
_, code = split_symbol(normalized)
raw = self._call_bars(code, mootdx_market(normalized), category, limit)
records = self._records(raw)
bars = []
for item in records:
trade_date = parse_date(item.get("datetime") or item.get("date") or item.get("time"))
if trade_date is None:
continue
bars.append(
KlineBar(
symbol=normalized,
period=period,
trade_date=trade_date,
open=parse_float(item.get("open")),
high=parse_float(item.get("high")),
low=parse_float(item.get("low")),
close=parse_float(item.get("close")),
volume=parse_float(item.get("vol") or item.get("volume")),
amount=parse_float(item.get("amount")),
source="mootdx",
)
)
return bars[-limit:]
def fetch_snapshot(self, symbol: str) -> Snapshot:
"""
功能说明:通过 mootdx 获取实时行情快照。
参数说明:symbol 为股票代码。
返回值说明:返回统一 Snapshot 模型。
注意事项:mootdx 快照字段因版本可能不同,缺失字段统一返回 None。
"""
normalized = normalize_symbol(symbol)
_, code = split_symbol(normalized)
raw = self.client.quotes(symbol=[code], market=mootdx_market(normalized))
records = self._records(raw)
if not records:
raise ValueError("mootdx 未返回快照: %s" % symbol)
item = records[0]
price = parse_float(item.get("price") or item.get("now") or item.get("last_close"))
previous_close = parse_float(item.get("last_close") or item.get("pre_close"))
return Snapshot(
symbol=normalized,
name=item.get("name"),
trade_time=None,
price=price,
previous_close=previous_close,
open=parse_float(item.get("open")),
high=parse_float(item.get("high")),
low=parse_float(item.get("low")),
volume=parse_float(item.get("vol") or item.get("volume")),
amount=parse_float(item.get("amount")),
change=None if price is None or previous_close is None else price - previous_close,
change_percent=None if price is None or previous_close in (None, 0) else (price - previous_close) / previous_close * 100,
turnover_rate=parse_float(item.get("turnover")),
pe_ttm=parse_float(item.get("pe_ttm") or item.get("pe")),
pe_static=None,
pb=parse_float(item.get("pb")),
market_cap=None,
float_market_cap=None,
limit_up=None,
limit_down=None,
source="mootdx",
)
def _call_bars(self, code: str, market: int, category: int, limit: int) -> Any:
"""
功能说明:兼容调用不同版本 mootdx 的 bars 接口。
参数说明:code 为六位股票代码,market 为市场编号,category 为周期编号,limit 为数量。
返回值说明:返回 mootdx 原始结果对象。
注意事项:不同 mootdx 版本参数名存在差异,因此保留一次兼容重试。
"""
try:
return self.client.bars(symbol=code, market=market, category=category, count=limit)
except TypeError:
return self.client.bars(symbol=code, market=market, frequency=category, offset=0)
def _records(self, raw: Any) -> List[Dict[str, Any]]:
"""
功能说明:把 mootdx 返回值转换为字典列表。
参数说明:raw 为 DataFrame、字典列表或其他可迭代对象。
返回值说明:返回普通 dict 列表。
注意事项:处理 datetime 同时存在于索引和列时的 reset_index 冲突。
"""
if hasattr(raw, "to_dict"):
data = raw
if hasattr(data, "columns") and "datetime" in data.columns:
data = data.copy()
if hasattr(data, "reset_index"):
try:
data = data.reset_index()
except ValueError:
data = data.drop(columns=["datetime"]).reset_index()
return data.to_dict("records")
if isinstance(raw, list):
return raw
return list(raw or [])
+124
View File
@@ -0,0 +1,124 @@
"""腾讯财经数据源适配器。"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from sidecar.models import KlineBar, Snapshot, parse_float
from sidecar.symbols import normalize_symbol
class TencentFinanceSource:
"""腾讯财经行情适配器。"""
def __init__(self, timeout_seconds: int = 8) -> None:
"""
功能说明:创建腾讯财经数据源。
参数说明:timeout_seconds 为 HTTP 请求超时时间。
返回值说明:无返回值。
注意事项:腾讯接口无需 Key,但字段为 GBK 编码和波浪线分隔。
"""
self.timeout_seconds = timeout_seconds
def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
"""
功能说明:获取 K 线数据。
参数说明:symbol 为股票代码,period 为周期,limit 为最大条数。
返回值说明:腾讯适配器暂不提供 K 线,固定抛出 NotImplementedError。
注意事项:本项目 K 线优先由 mootdx 提供。
"""
raise NotImplementedError("腾讯财经适配器暂不提供 K 线")
def fetch_snapshot(self, symbol: str) -> Snapshot:
"""
功能说明:获取腾讯财经实时行情快照。
参数说明:symbol 为股票代码。
返回值说明:返回统一 Snapshot 模型。
注意事项:PB 使用索引 46,PE_TTM 使用索引 39,避免常见错误映射。
"""
normalized = normalize_symbol(symbol)
query = urlencode({"q": normalized})
request = Request(
"https://qt.gtimg.cn/?" + query,
headers={"User-Agent": "Mozilla/5.0 TortoiseSidecar/0.1"},
)
with urlopen(request, timeout=self.timeout_seconds) as response:
text = response.read().decode("gbk", errors="ignore")
fields = parse_tencent_payload(text)
return snapshot_from_tencent_fields(normalized, fields)
def parse_tencent_payload(payload: str) -> List[str]:
"""
功能说明:解析腾讯财经原始响应。
参数说明:payload 为形如 v_sh600000="..." 的响应文本。
返回值说明:返回按波浪线拆分后的字段列表。
注意事项:响应为空或格式异常时抛出 ValueError。
"""
if '="' not in payload:
raise ValueError("腾讯财经响应格式异常")
content = payload.split('="', 1)[1].split('"', 1)[0]
fields = content.split("~")
if len(fields) < 53:
raise ValueError("腾讯财经响应字段不足")
return fields
def snapshot_from_tencent_fields(symbol: str, fields: List[str]) -> Snapshot:
"""
功能说明:把腾讯财经字段转换为统一快照。
参数说明:symbol 为标准股票代码,fields 为腾讯响应字段列表。
返回值说明:返回 Snapshot 模型。
注意事项:字段索引按项目文档校准,43 是振幅不是 PB。
"""
return Snapshot(
symbol=symbol,
name=_field(fields, 1) or None,
trade_time=_parse_trade_time(_field(fields, 30)),
price=parse_float(_field(fields, 3)),
previous_close=parse_float(_field(fields, 4)),
open=parse_float(_field(fields, 5)),
high=parse_float(_field(fields, 33)),
low=parse_float(_field(fields, 34)),
volume=parse_float(_field(fields, 6)),
amount=parse_float(_field(fields, 37)),
change=parse_float(_field(fields, 31)),
change_percent=parse_float(_field(fields, 32)),
turnover_rate=parse_float(_field(fields, 38)),
pe_ttm=parse_float(_field(fields, 39)),
pe_static=parse_float(_field(fields, 52)),
pb=parse_float(_field(fields, 46)),
market_cap=parse_float(_field(fields, 44)),
float_market_cap=parse_float(_field(fields, 45)),
limit_up=parse_float(_field(fields, 47)),
limit_down=parse_float(_field(fields, 48)),
source="tencent",
)
def _field(fields: List[str], index: int) -> str:
"""
功能说明:安全读取腾讯字段。
参数说明:fields 为字段列表,index 为字段索引。
返回值说明:字段存在返回原始字符串,不存在返回空字符串。
注意事项:腾讯字段偶发缺失时可避免 IndexError。
"""
return fields[index] if index < len(fields) else ""
def _parse_trade_time(value: str) -> Optional[datetime]:
"""
功能说明:解析腾讯交易时间字段。
参数说明:value 为 YYYYMMDDHHMMSS 格式字符串。
返回值说明:解析成功返回 datetime,空值或异常返回 None。
注意事项:返回值不带时区,表示交易所本地时间。
"""
if not value:
return None
try:
return datetime.strptime(value[:14], "%Y%m%d%H%M%S")
except ValueError:
return None