linux-web: B/S 架构——采集引擎后台线程 + aiohttp WebSocket 实时推送 + Web 仪表盘 + systemd 部署

This commit is contained in:
lookt
2026-09-15 20:09:46 +08:00
parent 2c72de1dba
commit f6bdc5a983
8 changed files with 633 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""
时间线采集引擎(跨平台):将 realtime_analyzer 的分析循环重构为可嵌入的采集器。
- 盘前:美股隔夜收盘 -> 实证先验(a-stock-timeline-patterns skill)输出今日预估
- 盘中:东财 7x24 快讯增量入库 + 关键词告警 + 上证实时点位
- 盘后:龙虎榜入库 + 收盘日报
事件通过回调 emit(event: dict) 交给上层(桌面版写文件,B/S 版走 WebSocket 广播)。
"""
import json
import os
import sqlite3
import traceback
from datetime import datetime, timedelta
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent.parent
DB = ROOT / 'data' / 'a_stock.db'
FEED = ROOT / 'data' / 'realtime_feed.jsonl'
SKILL_REF = Path(r'E:\Data\skills\a-stock-timeline-patterns\references\overnight_transmission.md')
KEYWORDS = ['降息', '降准', '加息', '关税', '制裁', '收购', '重组', '国债', '证监会', 'PMI', 'CPI']
# 出站请求域名白名单(SSRF 防护)
_ALLOWED_HOSTS = {'np-weblist.eastmoney.com', 'np-listapi.eastmoney.com'}
def _safe_get(url, params=None, timeout=10):
from urllib.parse import urlparse
u = urlparse(url)
if u.scheme != 'https' or u.hostname not in _ALLOWED_HOSTS:
raise ValueError('blocked non-allowlist url: %s' % url)
return requests.get(url, params=params, timeout=timeout,
headers={'User-Agent': 'Mozilla/5.0'}, allow_redirects=False)
class TimelineCollector:
"""常驻采集引擎:一个线程安全的同步循环,由上层调度(线程/异步 executor)"""
def __init__(self, emit, db_path=None):
self.emit = emit # callable(dict)
self.db_path = str(db_path or DB)
self.state = {'date': datetime.now().strftime('%Y-%m-%d')}
self.priors = {}
self._load_priors()
# ---------- 基础 ----------
def _conn(self):
conn = sqlite3.connect(self.db_path, check_same_thread=False)
return conn
def _emit(self, kind, data):
rec = {'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'kind': kind, 'data': data}
try:
FEED.parent.mkdir(parents=True, exist_ok=True)
with open(FEED, 'a', encoding='utf-8') as f:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
except OSError:
pass
self.emit(rec)
# ---------- 实证先验(skill 注入) ----------
def _load_priors(self):
import re
self.priors = {}
path = str(SKILL_REF)
if not os.path.exists(path):
# Linux 部署:skill 文件可放项目 docs/ 下
alt = Path(__file__).resolve().parent.parent.parent / 'docs' / 'overnight_transmission.md'
if not alt.exists():
return
path = str(alt)
try:
with open(path, encoding='utf-8') as f:
cur_us = cur_idx = None
for line in f:
m = re.match(r'### (\S+)T日 -> (\S+)T\+1日', line)
if m:
cur_us, cur_idx = m.group(1), m.group(2)
continue
m = re.match(
r'- 美股(\S+?)(\d+)天): A股跳空 N=\d+, 均值 (-?[\d.]+)%, 中位数 (-?[\d.]+)%, '
r'胜率 ([\d.]+)%; 日内 N=\d+, 均值 (-?[\d.]+)%, 中位数 (-?[\d.]+)%, 胜率 ([\d.]+)%',
line)
if m and cur_us and cur_idx:
self.priors[(cur_us, cur_idx, m.group(1))] = {
'n': int(m.group(2)),
'gap_mean': float(m.group(3)), 'gap_median': float(m.group(4)),
'gap_win': float(m.group(5)),
'intra_mean': float(m.group(6)), 'intra_median': float(m.group(7)),
'intra_win': float(m.group(8)),
}
except OSError:
pass
# ---------- 数据获取 ----------
def us_overnight(self, conn):
out = {}
for code in ('US.NDX', 'US.SPX', 'US.DJI'):
row = conn.execute(
"SELECT trade_date, pct_change FROM global_index WHERE index_code=? "
"ORDER BY trade_date DESC LIMIT 1", (code,)).fetchone()
if row:
out[code] = {'date': row[0], 'pct': row[1] or 0.0}
return out
def forecast_from_priors(self, us):
if not self.priors or 'US.NDX' not in us:
return None
pct = us['US.NDX']['pct'] / 100.0
if pct <= -0.015:
b = '跌>1.5%'
elif pct <= -0.005:
b = '跌0.5~1.5%'
elif pct < 0.005:
b = '正负0.5%'
elif pct < 0.015:
b = '涨0.5~1.5%'
else:
b = '涨>1.5%'
k = ('US.NDX', 'sh000001', b)
if k not in self.priors:
return None
p = self.priors[k]
return ('隔夜预估[美股纳指{:+.2f}% -> 分箱"{}"]: 历史上上证次日跳空均值 {:+.2f}%'
'(低开概率 {:.0f}%),日内均值 {:+.2f}%(日内收涨概率 {:.0f}%),全天均值 {:+.2f}%'
'(样本{}天,美股先验,仅参考)').format(
us['US.NDX']['pct'], b, p['gap_mean'], 100 - p['gap_win'],
p['intra_mean'], p['intra_win'], p['gap_mean'] + p['intra_mean'], p['n'])
def index_spot_sh(self):
try:
import akshare as ak
df = ak.stock_zh_index_spot_em(symbol='上证系列指数')
row = df[df['名称'] == '上证指数']
if not row.empty:
r = row.iloc[0]
return {'price': float(r['最新价']), 'pct': float(r['涨跌幅'])}
except Exception:
pass
try:
import akshare as ak
df = ak.stock_zh_index_spot_sina()
row = df[df['代码'] == 'sh000001']
if not row.empty:
r = row.iloc[0]
return {'price': float(r['最新价']), 'pct': float(r['涨跌幅'])}
except Exception:
pass
return None
def fetch_em_flash(self, conn):
"""东财 7x24 快讯增量入库,返回 (time, text) 新增列表"""
fresh = []
try:
r = _safe_get('https://np-weblist.eastmoney.com/comm/web/getFastNewsList',
params={'client': 'web', 'biz': 'web_724', 'fastColumn': '102',
'sortEnd': '', 'pageSize': '20', 'req_trace': '1'})
data = r.json().get('data', {}) or {}
for n in data.get('fastNewsList', []) or []:
ts = n.get('showTime', '')
summary = (n.get('summary') or n.get('title') or '').strip()
if not ts or not summary:
continue
dup = conn.execute(
"SELECT 1 FROM news_flash WHERE event_time=? AND content=? LIMIT 1",
(ts, summary)).fetchone()
if not dup:
conn.execute(
"INSERT INTO news_flash(content, event_time, source, inserted_at) "
"VALUES (?,?,?,datetime('now','localtime'))", (summary, ts, '东财7x24'))
fresh.append((ts, summary[:60]))
conn.commit()
except Exception as e:
self._emit('采集错误', {'msg': str(e)[:120]})
return fresh
def save_lhb_today(self, conn):
try:
from src.fetcher.lhb_fetcher import fetch_lhb
from src.storage.db import upsert_rows
df = fetch_lhb()
if df is not None and not df.empty:
return upsert_rows(df, 'lhb_daily',
conflict_cols=['trade_date', 'ts_code', 'reason'])
except Exception as e:
self._emit('采集错误', {'msg': str(e)[:120]})
return 0
def news_alerts(self, conn):
hits = []
cutoff = (datetime.now() - timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')
for table, tcol, ccol in (('news_flash', 'event_time', 'content'),
('news_cn', 'pub_date', 'title')):
try:
rows = conn.execute(
"SELECT {}, {} FROM {} WHERE {} >= ? ORDER BY {} DESC LIMIT 50".format(
tcol, ccol, table, tcol, tcol), (cutoff,)).fetchall()
except sqlite3.OperationalError:
continue
for ts, text in rows:
for kw in KEYWORDS:
if kw in (text or ''):
hits.append({'time': ts, 'kw': kw, 'text': (text or '')[:80]})
break
return hits
# ---------- 单轮采集 ----------
def one_cycle(self):
conn = self._conn()
try:
now = datetime.now()
hm = now.hour * 100 + now.minute
if 700 <= hm < 925 and not self.state.get('premarket_done'):
us = self.us_overnight(conn)
fc = self.forecast_from_priors(us)
self._emit('盘前隔夜预估', {'us': us, 'forecast': fc})
self.state['premarket_done'] = True
if 925 <= hm < 1505:
for ts, txt in self.fetch_em_flash(conn):
self._emit('新快讯', {'time': ts, 'text': txt})
spot = self.index_spot_sh()
if spot and spot.get('price', 0) > 0:
self._emit('盘中点位', {'上证': spot['price'], '涨跌幅%': spot['pct']})
for h in self.news_alerts(conn):
self._emit('快讯关键词告警', h)
if hm >= 1510 and not self.state.get('postmarket_done'):
n = self.save_lhb_today(conn)
sh = conn.execute("SELECT trade_date, close FROM stock_daily "
"WHERE ts_code='sh000001' ORDER BY trade_date DESC LIMIT 1").fetchone()
self._emit('收盘日报', {'龙虎榜新增': n, '上证最新收盘': sh})
self.state['postmarket_done'] = True
if self.state.get('date') != now.strftime('%Y-%m-%d'):
self.state.clear()
self.state['date'] = now.strftime('%Y-%m-%d')
self._load_priors()
self._emit('日切', {'date': self.state['date']})
finally:
conn.close()
def run_forever(self, interval=60, on_error=None):
"""阻塞式常驻循环(Linux 服务/桌面版均可直接调用)"""
while True:
try:
self.one_cycle()
except Exception:
traceback.print_exc()
if on_error:
on_error()
time.sleep(interval)
if __name__ == '__main__':
c = TimelineCollector(emit=lambda rec: print(
'[{}] {} {}'.format(rec['ts'], rec['kind'],
json.dumps(rec['data'], ensure_ascii=False)), flush=True))
print('时间线采集引擎启动(Ctrl+C 停止)', flush=True)
c.run_forever()
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JQuant · A股时间线实时监控</title>
<style>
:root {
--bg: #0b0e14; --surface: #131722; --border: #1e2634;
--text: #e6e9f0; --text2: #9aa4b8;
--up: #f5455c; --down: #2fbf71; --accent: #4c8dff;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 14px;
}
header {
padding: 14px 22px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
background: var(--surface);
}
header h1 { margin: 0; font-size: 17px; }
header h1 span { color: var(--accent); }
.conn { font-size: 12px; padding: 3px 10px; border-radius: 10px; }
.conn.on { background: rgba(47,191,113,.15); color: var(--down); }
.conn.off { background: rgba(245,69,92,.15); color: var(--up); }
.stats { padding: 8px 22px; color: var(--text2); font-size: 12px; border-bottom: 1px solid var(--border); }
main { padding: 14px 22px; max-width: 1100px; margin: 0 auto; }
.filters { margin-bottom: 10px; }
.filters button {
background: var(--surface); color: var(--text2); border: 1px solid var(--border);
border-radius: 14px; padding: 4px 14px; margin-right: 6px; cursor: pointer; font-size: 12px;
}
.filters button.active { border-color: var(--accent); color: var(--accent); }
ul#feed { list-style: none; margin: 0; padding: 0; }
li.evt {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: baseline;
animation: slidein .25s ease;
}
li.evt.fresh { border-color: var(--accent); }
@keyframes slidein { from { transform: translateY(-6px); opacity: 0; } to { opacity: 1; } }
.t { color: var(--text2); font-size: 12px; min-width: 140px; font-variant-numeric: tabular-nums; }
.k { min-width: 110px; font-weight: 700; }
.k.盘中点位 { color: var(--accent); }
.k.新快讯 { color: #ffb74d; }
.k.快讯关键词告警 { color: var(--up); }
.k.盘前隔夜预估, .k.收盘日报 { color: var(--down); }
.d { flex: 1; word-break: break-all; color: #c3cad8; }
.empty { text-align: center; color: var(--text2); padding: 40px 0; }
</style>
</head>
<body>
<header>
<h1><span>JQ</span> · A股时间线实时监控 <small style="font-weight:400;font-size:12px;color:var(--text2)">B/SLinux 服务端推送)</small></h1>
<span id="conn" class="conn off">未连接</span>
</header>
<div class="stats">服务端:抓取(TDX/东财/同花顺)+ 分析(波动分解/事件归因)每 60s 一轮 → WebSocket 实时推送本页 | 缓冲事件 <b id="buf">0</b></div>
<main>
<div class="filters" id="filters">
<button data-f="" class="active">全部</button>
<button data-f="盘中点位">点位</button>
<button data-f="新快讯">快讯</button>
<button data-f="快讯关键词告警">告警</button>
<button data-f="盘前隔夜预估">盘前预估</button>
<button data-f="收盘日报">日报</button>
</div>
<ul id="feed"><li class="empty">等待服务端推送…</li></ul>
</main>
<script>
const feed = document.getElementById('feed')
const conn = document.getElementById('conn')
const buf = document.getElementById('buf')
let filter = ''
let ws, retryTimer
function render(e, fresh) {
if (filter && e.kind !== filter) return
const empty = feed.querySelector('.empty')
if (empty) empty.remove()
const li = document.createElement('li')
li.className = 'evt' + (fresh ? ' fresh' : '')
const d = typeof e.data === 'object' ? JSON.stringify(e.data) : (e.data ?? '')
li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${d.replace(/[<>&]/g, '')}</span>`
feed.prepend(li)
while (feed.children.length > 300) feed.lastChild.remove()
}
function applyRecent(events) {
[...events].reverse().forEach(e => render(e, false))
}
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(`${proto}://${location.host}/ws`)
ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'conn on' }
ws.onclose = () => {
conn.textContent = '已断开,重连中…'; conn.className = 'conn off'
retryTimer = setTimeout(connect, 3000)
}
ws.onmessage = m => {
try { render(JSON.parse(m.data), true) } catch (e) {}
}
}
document.getElementById('filters').addEventListener('click', ev => {
if (ev.target.dataset.f === undefined) return
filter = ev.target.dataset.f
document.querySelectorAll('.filters button').forEach(b => b.classList.toggle('active', b === ev.target))
fetch(`/api/recent`).then(r => r.json()).then(d => {
feed.innerHTML = ''
;[...(d.events || [])].reverse().forEach(e => render(e, false))
})
})
fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered)
fetch('/api/recent').then(r => r.json()).then(d => {
;[...(d.events || [])].reverse().forEach(e => render(e, false))
})
setInterval(() => fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered), 10000)
connect()
</script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
# -*- 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
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:
await loop.run_in_executor(None, collector.one_cycle)
await asyncio.sleep(60)
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 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('/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)