mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 19:14:19 +08:00
展示层对标专业看盘终端(tick-stock-panel 模式),数据全部来自通达信协议直连: - 市场看板:五大指数实时条(迷你分时)、涨跌统计、四维情绪雷达、 全市场涨跌分布直方图(约 5500 只 22 桶、鼠标跟随浮窗)、涨停雷达、 行业/概念热冷榜(可下钻)、涨幅/跌幅/成交额/换手四联排行榜、异动雷达 - 自选行情:6 位代码即加(symbol-info 自动取名)、SSE 实时全表刷新、 行内迷你分时;SQLite 持久化(watchlist.db,幂等加删) - 个股弹窗:五档盘口 + 1/3/5 日分时 + 日K(MA/BOLL/EMA + MACD/KDJ/RSI 前端本地计算)+ 一键加自选 + 一键全策略寻优 - 板块弹窗:板块分时/日K + 成分股涨跌榜直达个股 - SSE 架构:QuoteStreamer 单循环轮询 fan-out(独立队列 + 背压丢旧、 无订阅休眠、盘中 8s/盘外 60s 降频);前端 pinia 单连接指数退避重连 - fix(codec): SH 000 系大盘指数与 881/885 板块指数报价按 2 位小数解析 (曾缩小 10 倍:科创50 1647.53→164.753);880 统计指数保持 3 位 - fix: 前端批量五档路径 /security/quotes→/quotes、SSE 五档白名单 bid1_vol→bid_vol1、MAC 排行 close 列归一化、日K v-if 撑满、 寻优查看跳转 /→/backtest、FastAPI 0.141 _IncludedRouter 测试适配 新增 27 个单测(streamer/自选/小数位语义),全套 1078 passed
63 lines
2.1 KiB
Python
63 lines
2.1 KiB
Python
"""实时行情 SSE 路由。
|
||
|
||
``GET /api/v1/stream/quotes`` → ``text/event-stream``:
|
||
|
||
- 事件 ``data`` 载荷:``{"type": "quotes_updated", "ts", "count", "quotes": [...]}``
|
||
(quotes 为指数 + 全部自选的快照,字段见 quote_streamer._QUOTE_FIELDS)。
|
||
- 每 15s 一条 SSE 注释行(``: keepalive``)防中间层掐空闲连接。
|
||
- 客户端断开由 ASGI cancel → generator finally 反注册队列。
|
||
|
||
零额外依赖:不用 sse-starlette,StreamingResponse + asyncio.Queue 足够。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Request
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(tags=["stream"])
|
||
|
||
_KEEPALIVE_SECONDS = 15.0
|
||
|
||
|
||
@router.get("/stream/quotes")
|
||
async def stream_quotes(request: Request) -> StreamingResponse:
|
||
"""订阅实时行情推送(指数 + 自选,快照式全量推送)。"""
|
||
streamer = getattr(request.app.state, "quote_streamer", None)
|
||
if streamer is None:
|
||
from fastapi import HTTPException
|
||
|
||
raise HTTPException(status_code=503, detail="行情推送服务未启动")
|
||
|
||
qid, queue = streamer.subscribe()
|
||
|
||
async def event_gen(): # type: ignore[no-untyped-def]
|
||
try:
|
||
# 首帧 hello:告诉前端连接可用 + 当前订阅规模
|
||
hello = {"type": "hello", "subscribers": streamer.subscriber_count}
|
||
yield f"data: {json.dumps(hello, ensure_ascii=False)}\n\n"
|
||
while True:
|
||
try:
|
||
msg = await asyncio.wait_for(queue.get(), timeout=_KEEPALIVE_SECONDS)
|
||
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||
except asyncio.TimeoutError:
|
||
yield ": keepalive\n\n"
|
||
finally:
|
||
streamer.unsubscribe(qid)
|
||
|
||
return StreamingResponse(
|
||
event_gen(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no", # nginx 反代时不缓冲
|
||
},
|
||
)
|