观潮实战记:Dashboard 上线一周,57 条信号对了几条?

观潮实战记:Dashboard 上线一周,57 条信号对了几条?

接上回:观潮进化记——24 commits 从本地到云端。

那篇文章结尾我说了,下一步是"先停下来用"。用了一周,该交作业了。

scan_market v2:当所有板块接口都被封

上一篇留了个伏笔:以为部署到 Azure VM 就能解决 push2.eastmoney.com 的反爬问题。结果——VM 上也一样空回复。

更惨的是,我原本计划用板块接口(stock_board_industry_name_em)获取行业成分股做扫描——同样被封。东方财富的 push2 系列 API 对所有非浏览器环境一视同仁地返回 Empty reply,跟 DNS、地域、Headers 都无关。

唯一活着的是 stock_zh_a_hist(日 K 线,走 finance.eastmoney.com),每只约 1.8s。

于是方案从"动态板块扫描"变成了三级股票池:

优先级池子来源特殊处理
⭐ 最高持仓SQLite + 真实成本价显示浮盈/浮亏
🔵自选SQLite备注关注原因
⚪热门硬编码 8 赛道 70 只覆盖主要行业

三级去重,并发 5 线程拉 K 线 + 技术评分,5 分钟缓存。73 只股票,约 26 秒跑完。

小龙虾 Review 时提了个好问题:"持仓三只全看空,但广泰浮盈 +21%,这不矛盾吗?"——于是加了 context 字段:"技术面弱但仍在盈利区,可考虑设置移动止盈"。

Dashboard:300 行 HTML 直调 MCP

参考了我博客上另一个页面 chat_observatory.html 的模式——单文件 HTML,CSS+JS 内联,前端直接 fetch 后端 API。

区别在于 chat_observatory 调的是 REST API,而 TideWatch 是 MCP JSON-RPC。封装了一个 mcpCall() 函数:

async function mcpCall(tool, args = {}) {
  const resp = await fetch(CONFIG.mcpUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-API-Key': CONFIG.apiKey,
    },
    body: JSON.stringify({
      jsonrpc: '2.0', id: ++rpcId,
      method: 'tools/call',
      params: { name: tool, arguments: args }
    })
  });
  const data = await resp.json();
  return JSON.parse(data.result.content[0].text);
}

stateless_http=True 模式下直接 POST /mcp 调 tools/call,不需要先 initialize。

两阶段加载是杀手功能——点击个股卡片时,先从 scan 缓存秒出快速视图(名称/价格/评分/多空理由),然后异步调 analyze_stock 加载冲突检测和 LLM 叙事。用户看到的是"一闪就有数据,然后叙事渐渐出来",而不是"空白转圈 20 秒"。

文件带 API Key 所以 git-ignored,只在本地 zola serve 使用。

个股详情:双栏布局 + 渐进加载

点击主页卡片,弹出 1060px 宽的详情面板,左右双栏:

  • 左列:四维分析卡片(技术面/资金面/消息面/市场体制)+ 冲突检测 + 建议仓位
  • 右列:🤖 AI 深度分析(LLM 叙事)

加载分三阶段:

  1. Phase 1 (0ms):从 scan 缓存即时显示头部(名称/价格/评分)
  2. Phase 2 (~2-8s):analyze_stock(skip_llm=true) 跳过 LLM 秒出四维数据
  3. Phase 3 (异步):polish_narrative_llm 火并忘——LLM 完成后直接显示最终版

休盘期间(周末 + 15:05 后)有 detailCache 内存缓存,点过的股票再次打开 0ms 秒出。

威海广泰个股详情面板:左列四维分析 + 右列 AI 深度分析

运维战:CORS 五幕剧

Dashboard 上线后踩了一个巨坎——点击个股永远显示“Failed to fetch”或 CORS 错误。从息到完整解决经历了五幕:

第一幕:FastMCP 不处理 OPTIONS 浏览器发 CORS preflight(OPTIONS 请求),FastMCP 完全不响应,Nginx 等 300 秒超时。解决:Nginx 拦截 OPTIONS 返回 204。

第二幕:进程僵死 Python 进程端口在监听但不响应请求。systemctl restart 卡在 stop-sigterm 90 秒。解决:TimeoutStopSec=10 + RuntimeMaxSec=4h。

第三幕:预热与分析抢资源 后台预热线程 ThreadPoolExecutor(5) 和 analyze_stock 的 ThreadPoolExecutor(4) 同时抢 AKShare HTTP 连接——两边都慢到 4-5 分钟。解决:threading.Event 标志,analyze_stock 等预热完成再跑。

第四幕:asyncio 事件循环阻塞 async def analyze_stock 内部用 ThreadPoolExecutor + .result() 同步阻塞,卡死了整个 asyncio 事件循环,其他请求全部排队。解决:asyncio.to_thread() 把同步代码包装到独立线程。

第五幕:Nginx if is evil Nginx 的 if 块 + add_header 有经典冲突——if 存在时外层 add_header 失效。POST 响应没有 CORS 头,浏览器拒绝。解决:拆成独立 location = /mcp_preflight + rewrite 跳转,不再用 if 加头。

从第一幕到第五幕,关键误判是:以为 CORSMiddleware 能搞定一切,实际上 FastMCP 的 CORSMiddleware 对 POST 响应不加 CORS 头。教训:永远不要相信中间件的文档,用 curl 验证 HTTP 头。

后台预热:对冷启动说再见

scan_market 扫描 58 只股票约需 30 秒,如果用户打开 Dashboard 时还没扫完,就是白屏 30 秒。

解决方案:后台 daemon 线程,服务启动 3 秒后自动预热:

_warmup_thread = threading.Thread(target=_warmup_loop, daemon=True)
_warmup_thread.start()

盘中每 5 分钟自动刷新,盘后不刷新。Dashboard 永远命中热缓存,用户体验从“等 30 秒”变成“秒开”。

两个优化:同 VM 何必绕公网

CopilotX 本地调用

观潮的 LLM 叙事润色调 CopilotX API。之前链路是:

VM 上观潮 → HTTPS → api.polly.wang → Nginx → 127.0.0.1:24680

CopilotX 就跑在同一台 VM 上。改成 .env 里覆盖 COPILOTX_API_BASE=http://127.0.0.1:24680/v1,省掉 SSL 握手 + DNS + 外网往返。

并发数据拉取

analyze_stock 原来是串行的:K 线 → 技术分析 → 资金流向 → 新闻。但后三个跟 K 线完全没依赖。

改成 ThreadPoolExecutor(max_workers=4) 四路并发,K 线拿到后立即做技术分析,资金和新闻已经在旁边跑完了。小龙虾 Review 给了 9.5/10:"−4 行改动,+0 新依赖,逻辑等价,性能提升 ~37%。"


以上是 3/13 那天完成的全部工作。接下来是用了一周之后发生的事。

一周后:从 AKShare 到 baostock

用了几天,最大的痛点出现了:scan_market 偶尔超时。

Dashboard 的 mcpCall 有 60 秒超时。扫描 73 只股票,每只用 AKShare 的 stock_zh_a_hist 拉日K线需要 1.8 秒,5 线程并发约 26 秒——正常情况勉强跑完,但网络稍有波动就 timeout,前端弹出一个大红 Toast。

然后我发现了 baostock——一个基于 TCP 长连接的 A 股数据源。单只 K 线查询 0.28 秒,是 AKShare 的六分之一。零反爬,无 IP 限制。

迁移很干脆:data.py 的日K线全部切到 baostock,AKShare 只保留资金流向、新闻、龙虎榜、北向资金这些 baostock 没有的接口。

但新问题马上来了。

僵尸 TCP:三层保护的诞生

baostock 比 AKShare 快六倍,代价是它的单连接架构——整个进程共享一个 TCP session,所有查询排队走同一个 socket。

某天晚上 Dashboard 又白屏了。SSH 上去一看:进程还活着,端口还在监听,但所有请求卡死不响应。

跟踪发现 recv() 系统调用挂起——baostock 的 socket 在等一个永远不会来的回复。连接早断了,但 Python 侧不知道,没有超时,就这么永远等下去。

僵尸 TCP。最恶心的是:不报错、不断开、不崩溃,就是看起来一切正常但什么都不干。

修了三轮,每轮都以为搞定了,结果下一次又复现。最终方案是三层超时保护,小龙虾 Review 给了 🦞9.0/10:

层机制作用
第1层monkey-patch bs.connect() 注入 socket.settimeout(10)连接阶段超时
第2层_bs_login() 成功后再加一次 settimeout(10)覆盖 baostock 内部可能的重设
第3层异常统一走 _force_close_bs_socket()直接关 socket + 标记 session 失效

核心是第一层——baostock 源码里 connect() 创建 socket 后不设超时,默认跟着 OS 的 TCP keep-alive(几分钟到十几分钟)。monkey-patch 注入 10 秒超时后,卡死最多 10 秒就能发现并重连:

_original_connect = bs._connect

def _patched_connect():
    result = _original_connect()
    if bs._bs_socket:
        bs._bs_socket.settimeout(10)
    return result

bs._connect = _patched_connect

加上 threading.Lock(acquire timeout=15s) 保护线程安全 + 30 秒无活动自动重连。僵尸 TCP 问题根治。

池子瘦身:76 → 24

baostock 解决了单只速度,三层保护解决了连接可靠性。但——76 只股票还是太多了。加上技术分析计算开销,在 Azure VM 上仍然偶尔踩 60 秒线。

这时面临一个选择:把前端超时从 60 秒加到 120 秒? 还是把池子从 76 只减到能舒服跑完?

减量治本 > 加时治标。

增加超时只是推迟问题——用户多等一分钟,体验更差。减少池子,每只都是精选的行业龙头,反而更有价值。

新的 HOT_POOL:8 赛道 × 3 龙头 = 24 只

赛道龙头
大盘权重中国平安、招商银行、长江电力
新能源宁德时代、阳光电源、隆基绿能
AI/科技中芯国际、海光信息、寒武纪
消费贵州茅台、海天味业、伊利股份
医药恒瑞医药、迈瑞医疗、药明康德
有色/资源紫金矿业、天齐锂业、北方稀土
军工中航沈飞、航发动力、中航光电
汽车比亚迪、赛力斯、拓普集团

加上持仓 + 自选(去重),总共约 30 只。

结果:scan_market 从 ~60s 降到 4.25s。不是优化,是降维打击。

一周信号:57 条,对了几条?

从 3/12 上线到 3/18,信号追踪系统累计记录了 57 条信号,覆盖 9 只股票。

先看信号全景:

股票信号数主方向平均评分首次
威海广泰28持续看空-47.93/12
赛力斯10前空后多-36.43/12
和而泰8强烈看空-88.03/12
锦浪科技4持续看多+86.33/12
摩尔线程2看空-88.03/17
赣锋锂业2中性+7.03/17
陕西煤业1看多+63.03/15
中国神华1偏多+24.03/17
双汇发展1看多+73.03/15

几个有意思的发现:

威海广泰(我的重仓股)被连续看空 28 次,平均评分 -47.9,没有一次给过多。但它的持仓浮盈 +21%——这就是观潮设计冲突检测的初衷:技术面看空但你还在赚钱,这种矛盾比单纯的多空信号更有信息量。

和而泰平均 -88.0,8 次全看空,是最坚决的空头信号。锦浪科技反过来,4 次全看多,平均 +86.3。

赛力斯最有趣——前期看空(3/12 评分 -41,3/15 评分 -61),3/17 突然翻多到 +32,3/18 评分 +12 仍偏多。技术面转势?

但——

0 条回填结果。

57 条信号,全部 outcome_5d = null。原因很简单:update_signal_outcomes 需要手动调用来回填实际走势,而我一直在忙着修 bug、做手术。最早的信号已经过了 5 日窗口,理论上可以回填了——但我还没跑。

这是一个诚实的结论:引擎能记录信号,但验证信号的闭环还没跑通。 这不是技术问题,是纪律问题。

回望

一周(3/11→3/18),从零到 14 个工具,经历三场手术:

日期里程碑
3/11🌊 Phase 1:MCP 引擎诞生(7 工具,1618 行)
3/12Phase 2 + 3 + 4:信号追踪 + LLM 叙事 + 云端部署
3/13scan_market v2 + Dashboard + 并发优化 + CORS 五幕剧
3/15🔧 手术一:baostock 数据源迁移(1.8s → 0.28s/只)
3/16🔧 手术二:僵尸 TCP 三层保护(🦞9.0/10)
3/18🔧 手术三:HOT_POOL 瘦身 76→24(60s → 4.25s)

三篇博文对应三个阶段:

  • 观潮诞生记:两个半小时从零到一,引擎成型
  • 观潮进化记:24 commits 从本地到云端
  • 观潮实战记(本篇):Dashboard 上线 + 一周实战 + 三场手术

最深刻的一课:减量治本 > 加时治标——不是所有性能问题都该用"加"来解决,有时候"减"才是答案。

14 个 MCP 工具,tidewatch.polly.wang,扫描 30 只股票 4 秒出结果。57 条信号在积累,胜率验证——等我跑完回填再交作业。

开源在 GitHub: Polly2014/TideWatch-MCP-Server。

Comments