实现 sidecar 模块
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""数据源适配器包。"""
|
||||
@@ -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 表示。
|
||||
"""
|
||||
@@ -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 [])
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user