# -*- coding: utf-8 -*- """ B/S 服务端:时间线采集引擎(后台线程)+ WebSocket 实时推送 + Web 仪表盘 Linux 服务器常驻运行: python -m src.web.server (或 systemd,见 deploy/a-stock-timeline.service) 端口默认 8100,可用环境变量 PORT 覆盖。 """ import asyncio import json import os from collections import deque from pathlib import Path from aiohttp import WSMsgType, web import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from src.realtime.collector import TimelineCollector # noqa: E402 from src.analysis.event_impact import EventImpactService # noqa: E402 PORT = int(os.environ.get('PORT', '8100')) ROOT = Path(__file__).resolve().parent.parent.parent WEB_DIR = Path(__file__).resolve().parent RECENT_MAX = 500 recent = deque(maxlen=RECENT_MAX) # 最近事件(内存) clients = set() # 活跃 WS 连接 async def broadcast(event: dict): recent.append(event) payload = json.dumps(event, ensure_ascii=False) dead = set() for ws in clients: try: await ws.send_str(payload) except Exception: dead.add(ws) for ws in dead: clients.discard(ws) async def collector_task(_app): """把阻塞式采集循环放进线程池,事件桥接到 asyncio 广播""" loop = asyncio.get_running_loop() collector = TimelineCollector(emit=lambda rec: loop.call_soon_threadsafe( asyncio.ensure_future, broadcast(rec))) async def poll(): while True: try: await loop.run_in_executor(None, collector.one_cycle) except asyncio.CancelledError: raise except Exception: import traceback traceback.print_exc() await asyncio.sleep(60) task = asyncio.create_task(poll()) _app['impact'] = collector.impact # 启动即推最近历史(从 jsonl 恢复) feed = ROOT / 'data' / 'realtime_feed.jsonl' if feed.exists(): try: lines = feed.read_text(encoding='utf-8').strip().splitlines()[-200:] for line in lines: try: recent.append(json.loads(line)) except json.JSONDecodeError: pass except OSError: pass yield task.cancel() async def index(_request): return web.FileResponse(WEB_DIR / 'index.html') async def recent_events(_request): return web.json_response({'events': list(recent)}) async def impact_history(_request): collector = _request.app['impact'] return web.json_response({'events': collector.history(20)}) async def stats(_request): return web.json_response({'clients': len(clients), 'buffered': len(recent)}) async def ws_handler(request): ws = web.WebSocketResponse(heartbeat=30) await ws.prepare(request) clients.add(ws) await ws.send_str(json.dumps({'kind': '连接成功', 'data': {'msg': '实时推送已连接', 'buffered': len(recent)}}, ensure_ascii=False)) try: async for msg in ws: if msg.type == WSMsgType.ERROR: break finally: clients.discard(ws) return ws def build_app(): app = web.Application() app.router.add_get('/', index) app.router.add_get('/api/recent', recent_events) app.router.add_get('/api/stats', stats) app.router.add_get('/api/impact', impact_history) app.router.add_get('/ws', ws_handler) app.cleanup_ctx.append(collector_task) return app if __name__ == '__main__': print('JQuant 时间线 B/S 服务启动: http://0.0.0.0:{} (WS: /ws)'.format(PORT)) web.run_app(build_app(), host='0.0.0.0', port=PORT)