159 lines
4.9 KiB
Python
159 lines
4.9 KiB
Python
# -*- 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
|
||
import threading
|
||
import time
|
||
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
|
||
from src.quant.engine import QuantEngine # 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())
|
||
|
||
# 量化引擎:K线刷新 + 自适应打分 + 推荐卡推送
|
||
quant = QuantEngine(emit=lambda rec: loop.call_soon_threadsafe(
|
||
asyncio.ensure_future, broadcast(rec)), db_path=str(ROOT / 'data' / 'a_stock.db'))
|
||
_app['quant'] = quant
|
||
quant.start()
|
||
|
||
def _quant_loops():
|
||
while True:
|
||
try:
|
||
time.sleep(SCORING_INTERVAL_S)
|
||
quant.run_scoring_and_push()
|
||
except Exception as e:
|
||
print('[quant-loop]', e, flush=True)
|
||
time.sleep(60)
|
||
|
||
threading.Thread(target=_quant_loops, daemon=True, name='quant-scoring').start()
|
||
|
||
task = asyncio.create_task(poll())
|
||
# 启动即推最近历史(从 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 quant_recommendations(_request):
|
||
q = _request.app['quant']
|
||
return web.json_response({'ts': q.last_scored_at,
|
||
'list': (q.last_ranking or [])[:50]})
|
||
|
||
|
||
async def quant_weights(_request):
|
||
q = _request.app['quant']
|
||
return web.json_response(q.weights_store.load())
|
||
|
||
|
||
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('/api/quant/recommendations', quant_recommendations)
|
||
app.router.add_get('/api/quant/weights', quant_weights)
|
||
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)
|