Files
easy-tdx/src/easy_tdx/realtime/feed.py
T
Justin Gu afcc100e35 feat(realtime): 新增 RealtimeDataFeed 轮询数据源(issue #34)
EventBus 原本是纯发布/订阅管道,库内没有任何代码调用 publish(),导致
RealtimeStrategy 订阅后无数据可收(issue #34 根因)。

新增 RealtimeDataFeed:轮询 get_stock_quotes 快照 → 封装 MarketEvent →
bus.publish,让 RealtimeStrategy 拿来就能跑。

- feed.py: async(run_async) + sync(run_sync, executor 线程避免卡 loop)
  支持 interval/dedup(去重)/sessions(交易时段过滤,默认 9:15-11:30/13:00-15:00)
  symbol key 自动拼 f"{market}{code}" 匹配 EventBus.publish
- test_realtime_feed.py: 20 个单测覆盖 publish 路径/symbol key 匹配/
  去重开关/空 df/客户端异常不崩溃/盘外不拉取/stop 退出
- engine.py + __init__.py docstring: 「API 骨架待实现」→「需配合 feed 使用」
- README: 新增实时行情轮询 Python API 段落(含 issue #34 三个坑说明),
  WebSocket 段落标注「当前未联动数据源」现状
- examples/11_realtime/: 可直接运行的异步示例

通达信协议无服务端推送,本模块是约 3 秒延迟的快照轮询近似,非逐笔 tick。

验证:ruff + mypy(strict) + pytest 全量 956 passed(+20 新增),无回归。
2026-07-12 16:56:41 +08:00

324 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""轮询数据源:把 ``get_stock_quotes`` 的快照喂给 :class:`EventBus`。
通达信协议是请求/响应,没有服务端推送,所以「实时」只能靠**轮询五档快照**
近似实现。本模块提供 :class:`RealtimeDataFeed`,自动完成::
get_stock_quotes → MarketEvent → bus.publish
让 :class:`~easy_tdx.realtime.engine.RealtimeStrategy` 拿来就能跑,不必每个
用户都手写一遍轮询循环、symbol key 拼接、asyncio 调度。
.. note::
这是 **约 ``interval`` 秒延迟的快照轮询**,不是逐笔 tick。适合盘中信号
提醒、轻量监控;不适合高频 / 逐笔撮合。
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
import pandas as pd
from .engine import EventBus, EventType, MarketEvent
logger = logging.getLogger(__name__)
# Market 整数 → 字符串前缀。EventBus.publish 用 ``f"{market}{code}"`` 做 key
# 所以这里必须产出与订阅 key 一致的字符串前缀。
_MARKET_INT_TO_STR: dict[int, str] = {0: "SZ", 1: "SH", 2: "BJ"}
# A 股常规交易时段(含集合竞价)。盘外时段默认不轮询,避免空转 + 被服务器限流。
_DEFAULT_SESSIONS: tuple[tuple[int, int], ...] = (
(9 * 60 + 15, 11 * 60 + 30), # 09:15 - 11:30(含集合竞价)
(13 * 60 + 0, 15 * 60 + 0), # 13:00 - 15:00
)
_BATCH_SIZE = 80 # get_stock_quotes 单次上限(协议约束)
def _market_int_to_str(market: int) -> str:
"""把 ``Market`` 整数转成 ``EventBus`` 用的字符串前缀。
未知市场回退为十进制字符串,保证 ``publish`` key 可拼接、不丢事件。
"""
return _MARKET_INT_TO_STR.get(market, str(market))
def _is_in_session(now: float, sessions: tuple[tuple[int, int], ...]) -> bool:
"""判断 ``now``epoch 秒)的本地时分是否落在某个交易时段内。
空 ``sessions`` 表示不做时段过滤,始终返回 True。
"""
if not sessions:
return True
lt = time.localtime(now)
minute_of_day = lt.tm_hour * 60 + lt.tm_min
for start, end in sessions:
if start <= minute_of_day < end:
return True
return False
def _row_to_event(row: pd.Series, timestamp: float) -> MarketEvent | None:
"""把 quotes DataFrame 的一行转成 :class:`MarketEvent`。
- ``market`` 列是 int,转成字符串前缀以匹配 ``EventBus.publish`` 的 key。
- 最新价取 ``close`` 列(``get_stock_quotes`` 没有 ``price`` 列,最新成交价
落在 ``close``)。
- 缺列时回退为 0.0,保证事件仍可发布。
"""
code = str(row.get("code", ""))
if not code:
return None
market_raw = row.get("market")
try:
market_int = int(market_raw)
except (TypeError, ValueError):
market_int = -1
market_str = _market_int_to_str(market_int)
def _f(key: str) -> float:
try:
return float(row.get(key, 0.0) or 0.0)
except (TypeError, ValueError):
return 0.0
return MarketEvent(
event_type=EventType.TICK,
code=code,
market=market_str,
price=_f("close"),
volume=_f("vol"),
timestamp=timestamp,
data={
"open": _f("open"),
"high": _f("high"),
"low": _f("low"),
"pre_close": _f("pre_close"),
"amount": _f("amount"),
"name": str(row.get("name", "")),
},
)
@dataclass
class _FeedState:
"""跨周期去重用的上次价格/成交量缓存。"""
last: dict[str, tuple[float, float]] = field(default_factory=dict)
def changed(self, key: str, price: float, volume: float) -> bool:
prev = self.last.get(key)
self.last[key] = (price, volume)
return prev != (price, volume)
class RealtimeDataFeed:
"""轮询 ``get_stock_quotes`` 并把快照发布到 :class:`EventBus`。
同时支持 **异步客户端**(推荐,:meth:`run_async`)和 **同步客户端**
:meth:`run_sync`,把阻塞调用丢到 executor 线程,避免卡住事件循环)。
用法(异步,最常见)::
from easy_tdx.mac.client import AsyncMacClient
from easy_tdx.realtime.engine import EventBus, RealtimeStrategy
from easy_tdx.realtime.feed import RealtimeDataFeed
bus = EventBus()
bus.subscribe("SZ000001", MyStrategy().on_tick)
feed = RealtimeDataFeed(
bus=bus,
symbols=[(0, "000001"), (1, "600519")], # [(Market.SZ, code), ...]
)
async with AsyncMacClient.from_best_host() as client:
await feed.run_async(client)
用法(同步客户端)::
from easy_tdx.mac.client import MacClient
feed = RealtimeDataFeed(bus=bus, symbols=[(0, "000001")])
with MacClient.from_best_host() as client:
feed.run_sync(client) # 阻塞,直到 Ctrl+C 或 feed.stop()
Attributes:
bus: 目标事件总线。
symbols: ``[(market_int, code), ...]`` 列表,单次最多 80 只(协议上限)。
interval: 轮询间隔(秒),默认 3.0。
dedup: 是否对 ``(price, volume)`` 未变化的标的跳过发布,默认 True。
sessions: 交易时段(``[(start_min, end_min), ...]``,分钟数),
盘外时段只睡眠不拉取;传空 tuple 表示不做时段过滤(全天轮询)。
fields: 透传给 ``get_stock_quotes`` 的字段选择,默认 None 用客户端默认。
"""
def __init__(
self,
bus: EventBus,
symbols: Iterable[tuple[int, str]],
*,
interval: float = 3.0,
dedup: bool = True,
sessions: tuple[tuple[int, int], ...] | None = None,
fields: object = None,
) -> None:
symbol_list = list(symbols)
if not symbol_list:
raise ValueError("symbols 不能为空")
if len(symbol_list) > _BATCH_SIZE:
raise ValueError(
f"symbols 最多 {_BATCH_SIZE} 只(get_stock_quotes 单次上限),"
f"当前传了 {len(symbol_list)} 只"
)
self._bus = bus
self._symbols = symbol_list
self._interval = max(0.1, interval)
self._dedup = dedup
self._sessions = _DEFAULT_SESSIONS if sessions is None else sessions
self._fields = fields
self._state = _FeedState()
self._running = False
@property
def running(self) -> bool:
"""是否正在轮询。"""
return self._running
async def run_async(
self,
client: Any,
*,
max_iterations: int | None = None,
) -> None:
"""异步轮询循环(主推入口)。
Args:
client: 拥有 ``async def get_stock_quotes`` 的客户端
(如 :class:`~easy_tdx.mac.client.AsyncMacClient`)。
max_iterations: 最多轮询多少轮(测试用);None 表示无限循环直到
:meth:`stop`。
"""
self._running = True
try:
count = 0
while self._running:
if max_iterations is not None and count >= max_iterations:
break
count += 1
await self._poll_once_async(client)
await self._sleep_or_stop()
finally:
self._running = False
def run_sync(
self,
client: Any,
*,
max_iterations: int | None = None,
) -> None:
"""同步轮询循环(阻塞调用方)。
同步客户端的 ``get_stock_quotes`` 是阻塞 socket 调用,直接放进 asyncio
事件循环会卡死整个 loop。本方法把每次拉取丢到默认 executor 线程执行,
发布事件仍走事件循环,从而既不卡 loop、又不用换异步客户端。
Args:
client: 拥有同步 ``get_stock_quotes`` 的客户端
(如 :class:`~easy_tdx.mac.client.MacClient`)。
max_iterations: 最多轮询多少轮(测试用)。
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
raise RuntimeError(
"检测到正在运行的事件循环;请在循环内改用 "
"await feed.run_async(...) 而非 feed.run_sync(...)"
)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._run_sync_loop(client, max_iterations))
finally:
if not loop.is_running():
loop.close()
async def _run_sync_loop(self, client: Any, max_iterations: int | None) -> None:
"""同步客户端的轮询循环:阻塞调用丢到 executor。"""
self._running = True
try:
count = 0
while self._running:
if max_iterations is not None and count >= max_iterations:
break
count += 1
await self._poll_once_sync(client)
await self._sleep_or_stop()
finally:
self._running = False
def stop(self) -> None:
"""请求停止轮询(下一轮 sleep 结束后生效)。"""
self._running = False
# ------------------------------------------------------------------ #
# 内部
# ------------------------------------------------------------------ #
async def _poll_once_async(self, client: Any) -> None:
now = time.time()
if not self._in_session(now):
return
try:
df = await client.get_stock_quotes(self._symbols, self._fields)
except Exception:
logger.exception("async get_stock_quotes 失败,本轮跳过")
return
await self._publish_df(df, now)
async def _poll_once_sync(self, client: Any) -> None:
now = time.time()
if not self._in_session(now):
return
loop = asyncio.get_event_loop()
try:
df = await loop.run_in_executor(
None, client.get_stock_quotes, self._symbols, self._fields
)
except Exception:
logger.exception("sync get_stock_quotes 失败,本轮跳过")
return
await self._publish_df(df, now)
async def _publish_df(self, df: pd.DataFrame, timestamp: float) -> None:
if df is None or df.empty:
return
for _, row in df.iterrows():
event = _row_to_event(row, timestamp)
if event is None:
continue
key = f"{event.market}{event.code}"
if self._dedup and not self._state.changed(key, event.price, event.volume):
continue
await self._bus.publish(event)
def _in_session(self, now: float) -> bool:
return _is_in_session(now, self._sessions)
async def _sleep_or_stop(self) -> None:
"""按 interval 睡眠,但每 0.5s 检查一次 stop 标志,缩短退出延迟。"""
elapsed = 0.0
step = 0.5
while elapsed < self._interval and self._running:
await asyncio.sleep(min(step, self._interval - elapsed))
elapsed += step