接上回:观潮进化记——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 叙事)
加载分三阶段:
- Phase 1 (0ms):从 scan 缓存即时显示头部(名称/价格/评分)
- Phase 2 (~2-8s):
analyze_stock(skip_llm=true)跳过 LLM 秒出四维数据 - Phase 3 (异步):
polish_narrative_llm火并忘——LLM 完成后直接显示最终版
休盘期间(周末 + 15:05 后)有 detailCache 内存缓存,点过的股票再次打开 0ms 秒出。

运维战: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.9 | 3/12 |
| 赛力斯 | 10 | 前空后多 | -36.4 | 3/12 |
| 和而泰 | 8 | 强烈看空 | -88.0 | 3/12 |
| 锦浪科技 | 4 | 持续看多 | +86.3 | 3/12 |
| 摩尔线程 | 2 | 看空 | -88.0 | 3/17 |
| 赣锋锂业 | 2 | 中性 | +7.0 | 3/17 |
| 陕西煤业 | 1 | 看多 | +63.0 | 3/15 |
| 中国神华 | 1 | 偏多 | +24.0 | 3/17 |
| 双汇发展 | 1 | 看多 | +73.0 | 3/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/12 | Phase 2 + 3 + 4:信号追踪 + LLM 叙事 + 云端部署 |
| 3/13 | scan_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) |
三篇博文对应三个阶段:
最深刻的一课:减量治本 > 加时治标——不是所有性能问题都该用"加"来解决,有时候"减"才是答案。
14 个 MCP 工具,tidewatch.polly.wang,扫描 30 只股票 4 秒出结果。57 条信号在积累,胜率验证——等我跑完回填再交作业。

