接上回:观潮自省记——52 条信号回头看,AI 引擎学会了第四条纪律。
那篇讲的是让系统回头审视自己。这篇讲的是下一步:让它自己醒来。
8 秒
每天晚上打开观潮 Dashboard,同一个流程:
点开 → 骨架屏 → "正在加载..." → 8 秒后数据刷出来。
18 天了。每天 8 秒,不多,但每次都让我意识到一件事——是我在等它,不是它在等我。
这个系统有 14 个 MCP 工具、三级股票池、LLM 叙事引擎,已经部署在云端 24 小时在线。可它不会自己动。每次都得有人按一下按钮,它才开始干活。
就像养了一条会算命的鱼,但每次想知道运势,还得自己走过去敲鱼缸。
周日晚上,我终于问出了那个问题:为什么不让它自己跑?
不是来看潮的
事情其实不是从 TideWatch 开始的。
周日晚上本来是做 Azure VM 安全体检——小龙虾扫了一圈服务器,出了份 7.5 分的报告。过程中顺手拉了 CopilotX 的日志,发现全是扫描器在敲门:
46.151.182.85 - "GET /beta/.env HTTP/1.0" 401
46.151.182.85 - "GET /.aws/credentials HTTP/1.0" 401
46.151.182.85 - "POST /vendor/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.0" 401
一个 IP,36 条请求,从 .env 扫到 phpunit RCE 再到 AWS 密钥,教科书级的漏洞扫描。还有个执着的朋友 78.153.140.156,每小时回来扫一次 .env,全天三班倒。
好消息是全部 401 挡住了。坏消息是——fail2ban 居然没装。当天已经有 273 次 SSH 暴力破解尝试。
sudo apt-get install -y fail2ban
sudo systemctl enable --now fail2ban
一分钟搞定。刚装完就 ban 了第一个 IP:92.118.39.76,一个拿 oracle、admin、sol、solana 各种用户名爆破的家伙。
顺便杀掉一个幽灵进程——旧的 Blog Backend 在端口 50206 上跑了 30 天,kill 掉之后又自己复活了。原来背后有个 fastapi.service 在守护。systemctl disable 才算彻底送走。
修着修着,我盯着 ss -tlnp 的输出看到了这一行:
LISTEN 0 2048 0.0.0.0:8889 users:(("python",pid=1276173,fd=7))
TideWatch。就在同一台机器上。localhost:8889,零延迟。
三行 cron
方案出奇地简单。
不需要 GitHub Actions,不需要调度框架,不需要消息队列。一台机器上的两个进程,一个负责跑(TideWatch MCP Server),一个负责叫它起床(cron)。
# /home/azureuser/scripts/tidewatch-daily.sh
call_mcp "scan_market" '{"tier":"holdings"}' 120
call_mcp "scan_market" '{"tier":"watchlist"}' 120
call_mcp "update_signal_outcomes" '{}' 600
call_mcp 就是一个 curl,往 localhost:8889/mcp 发 JSON-RPC 请求。没有认证绕行(本地调用不走 Nginx),没有网络开销,没有序列化损耗。
调度:
30 7 * * 1-5 /home/azureuser/scripts/tidewatch-daily.sh
UTC 07:30 = 北京时间 15:30。A 股 15:00 收盘,留 30 分钟让数据落地,然后自动扫描持仓和自选。周一到周五,一天一次。
手动验证的时候发现一个小坑:FastMCP 的 Streamable HTTP 要求请求带 Accept: application/json header,否则返回 406 Not Acceptable。加上就好了。
小龙虾:"最简单的方案就是最好的方案。"
有时候工程师的直觉是加层抽象、建个服务、搞个队列。但这个场景不需要。cron 跑了几十年了,它不会忘记叫闹钟。
0 秒
数据每天 15:30 自动预热了,但 Dashboard 还不知道这件事。
打开页面它照样去调 scan_market,再等 8 秒。这就像冰箱里已经有做好的饭,但你每次回家还是先点外卖,等外卖到了才想起来冰箱里有。
Polly 改了四件事:
智能缓存判断:休盘期间如果本地缓存不到 4 小时——说明 cron 已经跑过了——直接用缓存,跳过 scan_market 调用。
cron 数据检测:如果缓存的时间戳落在 15:20-15:50 之间,自动识别为定时扫描数据,显示一个蓝色的 定时 徽章。
后台预加载:页面打开 3 秒后,静默拉取信号回填数据。等切到"信号复盘"tab 时直接渲染,不用再等。
休盘停止刷新:自动刷新定时器加了 isMarketOpen() 守卫,收盘后不再空转。
小龙虾 Review 给了 8.5 分,挑出一个边界 case:周末。
周五 15:30 的 cron 数据,到周六打开时已经超过 20 小时了,4 小时的缓存 TTL 兜不住,Dashboard 会尝试调 scan_market ——但周末没有新数据,白等。
修复很简单:周末给 48 小时的缓存窗口。
function isCacheFreshEnough(cacheTime) {
const ageMs = Date.now() - cacheTime;
if (isMarketOpen()) return ageMs < 5 * 60 * 1000; // 盘中:5 分钟
const day = new Date().getDay();
if (day === 0 || day === 6) return ageMs < 48 * 3600000; // 周末:48 小时
return ageMs < 4 * 3600000; // 工作日休盘:4 小时
}
最终缓存策略:
| 场景 | TTL | 说明 |
|---|---|---|
| 盘中 | 5 分钟 | 实时数据为主 |
| 工作日休盘 | 4 小时 | 覆盖 15:30→晚间 |
| 周末 | 48 小时 | 周五 cron 数据用到周日 |
小龙虾终审 9 分。
三层分工
退一步看,观潮现在有了清晰的三层架构:
┌─────────────────────────────────┐
│ Dashboard(展示层) │ ← 你打开时看到的
│ 智能缓存 · 相对时间 · 秒渲染 │
├─────────────────────────────────┤
│ Cron(调度层) │ ← 每天 15:30 自动触发
│ scan_market · signal backfill │
├─────────────────────────────────┤
│ MCP Server(逻辑层) │ ← 14 个工具,24h 在线
│ 分析 · 评分 · 叙事 · 回填 │
└─────────────────────────────────┘
MCP Server 负责所有逻辑和计算。Cron 负责定期触发,累积数据。Dashboard 负责展示。
这是第一版——最小可行的自动化。下一步可以把 cron 换成 Agent,让它不只是按时间跑,而是根据市场状态决定什么时候跑、跑什么、跑完之后要不要主动通知我。
但那是以后的事。现在,它每天 15:30 准时醒来,扫完持仓,写好日志,然后继续等。等我晚上打开 Dashboard 的时候,数据已经在那里了。
今晚的运维清单
顺带记一下今晚的 VM 运维成果,因为这些也是让观潮"活得更稳"的基础设施:
- fail2ban 安装启用 → SSH 暴力破解自动 ban
- 旧 Blog Backend 关停 → 端口 50206 释放,systemd 彻底 disable
- apt upgrade → 34 个包更新(含 grub + docker)
- OpenClaw 升级 → 2026.2.14 → 2026.3.28,模型切换到 claude-opus-4.6
一个投研系统不只是算法和数据。它还需要一台安全的机器、一个稳定的运行时、一个不会在半夜被扫描器打穿的端口。
潮来找我
回到开头的 8 秒。
明天周一开盘,15:30 收盘后,cron 会第一次正式运行。扫描持仓,扫描自选,回填信号。等我晚上打开 Dashboard,数据已经在那里等我了。
8 秒变 0 秒。不是因为更快了,是因为视角变了。
以前是我去看潮。现在是潮来找我。

