观潮进化记:24 commits,从本地引擎到云端投研搭档

观潮进化记:24 commits,从本地引擎到云端投研搭档

接上回:观潮诞生记——两个半小时搭了个 MCP-Native 的 AI 投研引擎。

那篇文章的结尾我说了,Phase 2 只做三件事:信号追踪、行为护栏、全市场扫描。实际上,第二天我做了远不止三件事。

24 个 commit,2495 行代码,从本地的 stdio 工具变成了 https://tidewatch.polly.wang/mcp 上的云端服务。这篇记录一下这个进化过程。

Phase 2:让引擎有记忆、有底线

信号追踪:时间的朋友

第一版观潮每次分析完就忘。分析了 14 只票,哪些涨了哪些跌了,全靠我自己记。

信号追踪系统用 SQLite 解决了这个问题。每次 analyze_stock 出结论,自动记一条:

#14 002111 看空(-32) @ 10.59  2026-03-12

5、10、20 日后自动回填实际走势,算胜率。这是时间的朋友——分析 100 只票之后,才能知道引擎到底准不准。

为了防止焦虑式重复分析(同一只票 5 分钟内看 3 次),加了 5 分钟去重窗口。

行为护栏:阻止你犯蠢

最好的交易系统不是告诉你买什么,而是阻止你犯蠢。

三条规则:

护栏触发条件提醒
追高检测20 日新高 + 涨幅 > 5%⚠️ "追高风险极高"
分析频次当天分析 > 10 只票📊 "是不是有点焦虑?聚焦 2-3 只最有把握的"
连续看空连续 3 次以上看空🔴 "要么市场真的不好,要么心态不对"

那天下午我连着分析了 13 只票,护栏真的弹出了:

📊 今天已经分析了 13 只票(002402, 512400, 601127, 601899, 300750...),是不是有点焦虑?分析太多反而犹豫不决。聚焦 2-3 只最有把握的,其他的放一放。

被自己的工具教育了一把。

Phase 3:让引擎会说话

LLM 叙事润色

Phase 1 的报告是模板拼接的——虽然信息密度高,但读起来像机器吐出来的表格:

"评分+17,偏多。均线上行。RSI 中性。主力净流入 466 万。"

加了 LLM 润色之后(通过 CopilotX API 调 Claude Sonnet 4),同样的数据变成了:

"002111 现在 10.59,这票有点纠结啊。技术面确实不太好看,跌破了 5 日线,离 20 日线也有点远,MACD 绿柱还在放大。但是!主力资金今天净流入 466 万,技术面在跌但资金在进,这种背离要么是洗盘,要么就是诱多。"

关键设计:LLM 失败时 fallback 到模板叙事,不能因为 API 抽风就整个分析挂掉。

ETF 也要分析

有人问了一句"帮我看看有色金属 ETF(512400)"——然后报错了。

原来 AKShare 的 A 股历史接口 stock_zh_a_hist 不支持 ETF,得用 fund_etf_hist_em。加了一个 _is_etf() 方法,根据代码前缀(51/15/16/56/58)自动路由到 ETF 接口。ETF 没有资金流向和新闻(东方财富不提供),分析时自动跳过。

Phase 4:从本地到云端

为什么要部署到云

两个原因。

第一,本地的 MCP Server 是 stdio 模式——VS Code 启动它、VS Code 关掉它。换台电脑或者用手机就没法用了。

第二,有个 bug 让我以为部署到服务器能修好(剧透:不能,后面会说)。

FastMCP 的优雅

Stylist-MCP-Server(我的另一个项目)用的是原生 MCP SDK + Starlette + 手写路由。观潮用的是 FastMCP 2.x,部署比 Stylist 简单多了。

核心代码只有这几行:

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--http", action="store_true")
    parser.add_argument("--port", type=int, default=8889)
    args = parser.parse_args()

    if args.http:
        mcp.run(transport="streamable-http", port=args.port,
                middleware=_build_middleware(), stateless_http=True)
    else:
        mcp.run()  # stdio

同一个引擎,poetry run tidewatch 是本地模式,加个 --http 就是远程 HTTP 服务。

API Key 认证

远程服务必须有认证。写了一个纯 ASGI 中间件,支持 X-API-Key header 和 Bearer token,/health 端点免认证:

无 Key  → POST /mcp  → 401 Unauthorized
错 Key  → POST /mcp  → 401 Unauthorized
对的 Key → POST /mcp  → MCP 正常握手 ✅
无需 Key → GET /health → 200 {"status":"healthy"} ✅

setup.sh 用 openssl rand -hex 12 自动生成 API Key,不用人工编密码。

部署五步走

客户端 (VS Code / Claude Desktop)
    │ HTTPS + API Key
    ▼
tidewatch.polly.wang:443 (Nginx + Let's Encrypt SSL)
    │ proxy_pass
    ▼
127.0.0.1:8889 (FastMCP Streamable HTTP)
    │ systemd 守护
    ▼
Poetry venv (Python 3.12 via miniforge3)

实际部署过程比画架构图精彩——Azure VM 上的 Python 是 3.8(Poetry 2.x 要求 3.10+),好在 Stylist 的 venv 里有 miniforge3 的 Python 3.12,借来用了。systemd 的 ExecStart 也踩了坑:Poetry 装在 miniforge3/bin/ 而不是 .local/bin/,第一次启动 status=203/EXEC,调了两版才跑起来。

最后的验证:

curl https://tidewatch.polly.wang/health
# {"status":"healthy","server":"TideWatch-观潮","version":"0.2.0",
#  "tools_count":10,"auth_enabled":true}

10 个工具,认证开启,HTTPS,在线。

一个反转的 Bug 故事

这个 debug 过程值得单独讲。

问题

scan_market(全市场扫描)在本地一直报 ConnectionError。AKShare 的 stock_zh_a_spot_em() 接口请求 push2.eastmoney.com,SSL 握手成功但返回 Empty reply(空响应)。

第一次误诊:DNS

用 nslookup 发现,Google DNS (8.8.8.8) 把 push2.eastmoney.com 解析到了腾讯云 IP 43.144.251.121,而国内 DNS (114.114.114.114) 解析到阿里云 IP 47.112.165.11。中间还有个 Azure Traffic Manager(push2ipv6.trafficmanager.cn)做区域路由。

我当时的结论:DNS 问题,部署到 Azure VM 上就好了。

这成了我推动云端部署的动力之一。

反转

部署完之后,兴冲冲地在 VM 上跑 scan_market——

{"error": "全市场数据获取失败,请稍后重试"}

VM 上也是一样的 Empty reply。DNS 解析到了不同的 IP(61.129.129.196,上海电信),但照样空回复。

第二次尝试:模拟浏览器

给 requests session 加了完整的浏览器 headers(User-Agent、Referer、Accept-Language)——还是空回复。

真相

push2.eastmoney.com 不是在检查 DNS 或 User-Agent。它对非浏览器环境的请求做了更深层的反爬限制——可能是 TLS 指纹、IP 信誉,或者其他我们看不到的机制。

有趣的是,AKShare 最常用的其他接口全部正常:

API域名状态
日K线 stock_zh_a_histfinance.eastmoney.com✅ 正常
资金流向push2his.eastmoney.com✅ 正常
新闻search-api-web.eastmoney.com✅ 正常
全市场实时 stock_zh_a_spot_empush2.eastmoney.com❌ 被封

只有 push2(实时行情推送服务)做了反爬。这是它的特殊性——实时行情有商业价值,东方财富不希望被程序化抓取。

解决方案

既然绕不过 push2,那就不用它。下一版 scan_market 会改成板块扫描模式:通过行业板块接口(走 datacenter-web,可用)获取热门行业的成分股,再批量拉 K 线做技术评分。不需要全市场 5000 只,扫热门板块 200-300 只就够了。

教训:先验证核心假设再做大改动。 如果我在 VM 上先测过 push2 再决定部署,可以省掉一些不必要的期待。但也没关系——云端部署本来就是该做的,只是动力来源搞反了。

回望

两天的 commit 历史铺开来看:

时间里程碑
3/11 23:36🌊 Phase 1:MCP 引擎诞生,5 模块 7 工具
3/12 00:15Phase 2:信号追踪 + 护栏 + 全市场扫描
3/12 00:33Phase 3:LLM 叙事润色(CopilotX + Claude Sonnet 4)
3/12 11:23ETF 支持(自动路由 stock vs fund API)
3/12 22:54Phase 4:HTTP 双模式 + API Key 认证
3/12 23:35✅ 云端部署完成:tidewatch.polly.wang/mcp

24 个 commit,从 1618 行到 2495 行,从 7 个工具到 10 个。从一个在 VS Code 里跑的本地脚本,变成了一个有认证、有 SSL、有 systemd 守护的云端投研引擎。

下一步?先让 scan_market 用板块扫描绕过反爬,然后积累足够的信号数据,看看这个引擎到底准不准。

毕竟,引擎是否有价值,不是我说了算,是时间和市场说了算。

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

Comments