初始化工程
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""行情状态 / SSE 推送 API。
|
||||
|
||||
盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
|
||||
SSE 推送三种事件 (使用标准 SSE event 字段):
|
||||
- quotes_updated: 行情数据刷新,前端 invalidate 对应 query
|
||||
- strategy_alert: 策略监控/告警触发,前端弹通知
|
||||
- depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
router = APIRouter(prefix="/api/intraday", tags=["quotes"])
|
||||
|
||||
|
||||
def _get_quote_service(request: Request):
|
||||
"""获取全局 QuoteService。"""
|
||||
return getattr(request.app.state, "quote_service", None)
|
||||
|
||||
|
||||
def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]:
|
||||
"""实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。"""
|
||||
repo = getattr(request.app.state, "repo", None)
|
||||
if not repo:
|
||||
return []
|
||||
|
||||
params: list[str] = []
|
||||
symbol_filter = ""
|
||||
if symbols:
|
||||
placeholders = ", ".join("?" for _ in symbols)
|
||||
symbol_filter = f"WHERE symbol IN ({placeholders})"
|
||||
params.extend(symbols)
|
||||
|
||||
try:
|
||||
rows = repo.execute_all(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT symbol, date, close,
|
||||
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
|
||||
FROM kline_index_daily
|
||||
{symbol_filter}
|
||||
), latest AS (
|
||||
SELECT symbol,
|
||||
max(CASE WHEN rn = 1 THEN date END) AS date,
|
||||
max(CASE WHEN rn = 1 THEN close END) AS last_price,
|
||||
max(CASE WHEN rn = 2 THEN close END) AS prev_close
|
||||
FROM ranked
|
||||
WHERE rn <= 2
|
||||
GROUP BY symbol
|
||||
)
|
||||
SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close
|
||||
FROM latest
|
||||
ORDER BY latest.symbol
|
||||
""",
|
||||
params,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
out: list[dict] = []
|
||||
for symbol, dt, last_price, prev_close in rows:
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
if last_price is not None and prev_close not in (None, 0):
|
||||
change_amount = float(last_price) - float(prev_close)
|
||||
change_pct = change_amount / float(prev_close) * 100
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"name": None,
|
||||
"date": str(dt) if dt else None,
|
||||
"last_price": float(last_price) if last_price is not None else None,
|
||||
"close": float(last_price) if last_price is not None else None,
|
||||
"prev_close": float(prev_close) if prev_close is not None else None,
|
||||
"change_amount": change_amount,
|
||||
"change_pct": change_pct,
|
||||
"source": "index_daily",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(request: Request):
|
||||
"""行情状态 (来自全局 QuoteService)。"""
|
||||
qs = _get_quote_service(request)
|
||||
if qs:
|
||||
return qs.status()
|
||||
return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0,
|
||||
"quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None}
|
||||
|
||||
|
||||
@router.get("/indices")
|
||||
def index_quotes(
|
||||
request: Request,
|
||||
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
|
||||
):
|
||||
"""返回实时指数行情缓存,不触发数据源请求。"""
|
||||
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
|
||||
qs = _get_quote_service(request)
|
||||
if not qs:
|
||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
||||
df = qs.get_index_quotes(symbol_list)
|
||||
rows = df.to_dicts() if not df.is_empty() else []
|
||||
if not rows:
|
||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
||||
return {"rows": rows, "count": len(rows), "source": "realtime"}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def quote_stream(request: Request):
|
||||
"""SSE 端点: 行情更新 + 告警推送 + 五档修正。
|
||||
|
||||
使用 sse-starlette EventSourceResponse:
|
||||
- 标准 SSE event 字段,前端按 event name 监听
|
||||
- 内置断线检测,客户端断开立即终止 generator
|
||||
- 内置 ping 心跳,保持连接活跃
|
||||
"""
|
||||
qs = _get_quote_service(request)
|
||||
|
||||
async def event_generator():
|
||||
while True:
|
||||
# 同时等待三类信号: 行情更新 / 告警 / 五档修正
|
||||
tasks: dict[str, asyncio.Future] = {
|
||||
"quote": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"alert": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"depth": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
}
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
list(tasks.values()),
|
||||
timeout=30.0,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
# 先推送告警 (如果有)
|
||||
if qs:
|
||||
alerts = qs.pop_alerts()
|
||||
if alerts:
|
||||
for chunk_start in range(0, len(alerts), 20):
|
||||
chunk = alerts[chunk_start:chunk_start + 20]
|
||||
yield {
|
||||
"event": "strategy_alert",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
"alerts": chunk,
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 推送行情更新 (行情信号触发)
|
||||
if tasks["quote"] in done:
|
||||
try:
|
||||
update_result = tasks["quote"].result()
|
||||
except Exception: # noqa: BLE001
|
||||
update_result = False
|
||||
if update_result:
|
||||
yield {
|
||||
"event": "quotes_updated",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
"symbol_count": qs._symbol_count if qs else 0,
|
||||
}),
|
||||
}
|
||||
|
||||
# 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据
|
||||
if tasks["depth"] in done:
|
||||
try:
|
||||
depth_result = tasks["depth"].result()
|
||||
except Exception: # noqa: BLE001
|
||||
depth_result = False
|
||||
if depth_result:
|
||||
yield {
|
||||
"event": "depth_updated",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
}),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_quotes(request: Request):
|
||||
"""手动刷新一次行情数据。"""
|
||||
qs = _get_quote_service(request)
|
||||
if qs:
|
||||
return qs.refresh()
|
||||
return {"error": "QuoteService not available"}
|
||||
Reference in New Issue
Block a user