生产部署
⚠️ 2026-07-05 更新:Web GUI 已退役——后端不再 serve 静态文件,没有网页面板;
sync_gui_dist/ Caddyfile_server前端段已删(重做时走独立前端连 MCP)。 Web API 本身也已 deprecated:存量端点只服务 remote hub 模式(INVEST_API_BASE转发)与内部触发,不再新增端点。多数用户不需要这一章——单机用户直接
pip install openinvest/uvx openinvest即可(见 QUICK_START)。这一章只服务"我要跑一个 remote hub 给多设备共享账本"的场景。
← 07-extending · Wiki 索引 · 09-troubleshooting →
拓扑总览
客户端(笔记本 CLI / agent skill,INVEST_API_BASE 转发)
↓ HTTPS
Cloudflare(DNS 橙色云 + Access JWT 鉴权)
↓ HTTP(Flexible 模式,源站不需要 cert)
你的服务器:80 / 443 端口
↓
Caddy 反代 (caddy-gateway 容器)
└─ /api/* → reverse_proxy 127.0.0.1:8765 (FastAPI, openinvest-web)为什么这套:
- 没有 SSR daemon → 一台 1G VPS 就能跑(参考 mc-website 教训:
next start整机三次挂死) - CF Access 在边缘鉴权 → 后端不写 auth 代码
127.0.0.1绑定 → 公网扫不到 8765
0. 容器一键自托管(Docker Compose / GHCR)
不想手装 uv / 配 systemd?用容器。镜像
ghcr.io/longsizhuo/openinvest每个后端版本 tag(v*)由publish-image.yml自动发布。GUI 已退役——容器只跑 API + scheduler, 没有网页面板。要 CF Access 保护,仍可在前面挂下面第 1–2 节的 Caddy(反代容器的127.0.0.1:8765)。
前置:Docker + Docker Compose v2。compose 文件在仓库里,所以这条路径要 clone (这是 hub 部署,不是普通用户安装——普通用户走 uvx openinvest):
git clone https://github.com/longsizhuo/openInvest.git && cd openInvest
cp .env.example .env && $EDITOR .env # 至少填 DEEPSEEK_API_KEY(没 .env 也能起,但委员会跑不动)onboarding(建 memory/)——invest-agent(scheduler)缺 memory/user.md 会拒启。 一次性命令走 invest-web(invest-agent 的 entrypoint: ["/bin/sh","-c"] 会吞掉追加参数):
docker compose run --rm invest-web openinvest init
# 或在 Claude Code 里说"帮我初始化 invest"走 5 个问题起服务:
docker compose up -d --build # 本地构建(首次几分钟:uv sync)
# —— 或拉预构建镜像(更快,需该 package 已 Public 或先 docker login ghcr.io)——
docker compose pull && docker compose up -d验证:curl http://localhost:8765/api/health(探活不需要 token)。
| 服务 | 作用 | 端口 |
|---|---|---|
invest-web | FastAPI(deprecated,仅 hub 转发 + 内部触发) | 宿主 127.0.0.1:8765(默认只绑 loopback) |
invest-agent | scheduler:跑 jobs/*.yml(daily_report / pnl_snapshot…) | 无 |
INVEST_API_TOKEN必填:容器内invest-web必须绑0.0.0.0(宿主端口映射才进得来),而openinvest-web入口的守卫规定"非 loopback 绑定 + 无 token = 拒绝启动"。compose 在起进程前先查这个变量,缺了直接给提示退出(不再靠127.0.0.1:8765端口映射兜底一个无鉴权的 API)。在.env加INVEST_API_TOKEN=$(openssl rand -hex 32)的结果;设了之后/api/*都要带Authorization: Bearer <token>(/api/health探活豁免)。docker compose run --rm invest-web openinvest init这类一次性命令会替换启动命令,不受影响。- 暴露到 LAN/公网:把
invest-web的ports改成"8765:8765"(token 已是必填);或保持 loopback、前面挂 Caddy + CF Access(见下文第 1–2 节)。 - 数据持久化:
memory/(账本,必挂)/db//cache_data//logs/都 bind-mount 到宿主,容器重建不丢。 - 镜像可见性:GHCR 包首发是 private——要
docker compose pull匿名拉,须在 GitHub Packages 把它设为 Public(或docker login ghcr.io)。
1. 服务器一次性配置
1.1 装后端(PyPI)+ 数据目录
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install openinvest # 或 pip install openinvest
mkdir -p ~/openInvest # INVEST_HOME 数据目录(memory/ db/ .env)
cp .env.example ~/openInvest/.env # 或手写;填 DEEPSEEK_API_KEY / EMAIL_* / 等
openinvest init # onboarding 建 memory/git clone 只用于开发后端本身。hub 服务器上装 wheel 即可;要 Docker compose / 自定义 systemd unit 模板才需要 clone 仓库拿那几个文件。
1.2 拉前端 dist(已退役)
2026-07-05 起 GUI 退役:
scripts.sync_gui_dist已删,FastAPI 不再 mountstatic/,Caddy 也不再需要 file_server 段。invest-gui 仓库封存待重做 (重做走独立前端连 MCP)。
1.3 systemd unit
仓库自带 unit 模板(systemd/invest-web.service),核心就是起 openinvest-web:
Environment=INVEST_HOME=%h/openInvest
EnvironmentFile=%h/openInvest/.env
Environment=INVEST_WEB_HOST=127.0.0.1
Environment=INVEST_WEB_PORT=8765
ExecStart=%h/.local/bin/uv run --no-sync openinvest-web
# host/port 走 env:INVEST_WEB_HOST(默认 127.0.0.1)/ INVEST_WEB_PORT(默认 8765)
# 必须走 openinvest-web 入口,不要裸 uvicorn——入口带"非 loopback 绑定必须设 INVEST_API_TOKEN"守卫
Restart=on-failure
ProtectSystem=strict
ReadWritePaths=%h/openInvestsudo cp systemd/invest-web.service /etc/systemd/system/ # 按需改路径
sudo systemctl daemon-reload
sudo systemctl enable --now invest-web
sudo systemctl status invest-web→ 仅写 INVEST_HOME 目录(systemd 加固)。 → Restart=on-failure:进程挂了自动起,但升级包后必须手动 restart 拉新。 → 端口已被占(比如旧进程没停干净):openinvest-web 以退出码 1退出并打印提示,Restart=on-failure(compose 的 restart: always 同理)每 15 秒重试直到端口空出,不会静默停在 inactive。
1.4 Caddy 站点配置
caddy-gateway/Caddyfile 加(只剩 API 反代,静态文件段已随 GUI 退役删除):
http://invest.your-domain.com {
encode gzip
handle /api/* {
reverse_proxy 127.0.0.1:8765
}
}改完 Caddyfile 必须 restart(不是 reload)—— bind mount 模式 reload 不读新 inode:
docker restart caddy详见 memory feedback_caddy_bind_mount_reload。
2. Cloudflare Access 配置
2.1 DNS
CF Dashboard → DNS → 加 invest A 记录指向服务器 IP,橙色云开启(走 CF proxy)。
2.2 Access Application
CF Zero Trust → Access → Applications → Add a self-hosted application:
| 字段 | 值 |
|---|---|
| Application name | invest |
| Application domain | invest.your-domain.com |
| Session duration | 30 days(或 24h,按你偏好) |
| Identity providers | One-time PIN(邮箱) |
2.3 Access Policy
Action: Allow
Include: Emails → your-email@gmail.com→ 仅你的邮箱能进。
2.4 验证
- 退出所有 CF 邮箱会话
curl -H "CF-Access-Client-Id: ..." -H "CF-Access-Client-Secret: ..." https://invest.your-domain.com/api/health- 无 Service Token 的裸请求应被 CF 挡在边缘
3. 升级流程
uv tool upgrade openinvest # 或 pip install -U openinvest;uvx 用户 uvx --refresh openinvest doctor
sudo systemctl restart invest-web重要:systemd unit 不会自动 reload 新代码。每次升级完必须 restart。
stdio MCP 子进程也要重启:agent 宿主(Hermes / OpenClaw / Claude Code 等)按 config 自己 spawn 的 openinvest-mcp stdio 子进程不归 systemd 管,升级包、重启 invest-web/scheduler 都碰不到它——它会一直跑旧代码,直到宿主重启该 MCP client (重启宿主 gateway/会话,或宿主自带的 MCP reload)。凡是改了 MCP 工具背后代码的 版本(例:2026-10 ingest_event 接上委员会触发闸),升级后要同时重启 agent 宿主的 MCP client,否则投喂门还是旧行为、爬虫门已是新行为。
(前端升级流程已随 GUI 退役删除——invest-gui 仓库封存待重做。)
4. 环境变量
必填
DEEPSEEK_API_KEY=sk-xxx
DEEPSEEK_BASE_URL=https://api.deepseek.com
INVEST_WEB_HOST=127.0.0.1
INVEST_WEB_PORT=8765可选
# CommSec 邮件导入(澳股用户)
EMAIL_SENDER=you@gmail.com
EMAIL_PASSWORD=app-password-16-chars
# 委员会跑完发邮件
SMTP_HOST=smtp.gmail.com
SMTP_USER=you@gmail.com
SMTP_PASS=app-password
SMTP_TO=you@gmail.com
# Discord DM 实时报警(可选):event_watch 事件 / 委员会 verdict 先推 Discord
# 再发邮件。invest 不直接持有 Discord token——POST 给同宿主机 Discord bot 的
# alert server(X-Internal-Key 内网鉴权),由它代发 DM。两个都不填 = 完全禁用,
# 行为与从前一致(邮件仍是保底归档通道)。
CHATBOT_ALERT_URL=http://127.0.0.1:6200/alert/invest
CHATBOT_INTERNAL_KEY=shared-secret-with-your-bot
# 事件触发的委员会 verdict 邮件/DM 里"详情"链接的前缀。不设默认退化成
# http://localhost:8765——在邮件/DM 里点开等于打自己电脑,读信设备打不开。
# 设成你自己 invest-web 的公网可达地址(第 1-2 节配好 Caddy + CF Access 后
# 就有)。就算不设,每个资产也会带一条 explain_decision(decision_id) 的
# agent 调用提示当零配置兜底,但能点开的链接体验更好。
INVEST_API_BASE_URL=https://invest.your-domain.com
# 委员会行为开关(也可运行时经 API/CLI 改,ADR-017;env 仅部署期默认)
# 集中度 lens:false=单资产/刻意集中/全可投资金池不因持仓集中度被建议减仓(ADR-019)
INVEST_VERDICT_CONCENTRATION_LENS_ENABLED=trueNapCat QQ bot connector 已于 2026-07-05 删除,相关
NAPCAT_*/INVEST_WHITELIST_QQenv 不再生效。
.env.example 是 source of truth
新增 env 必须更新 .env.example,否则 fork 用户不知道。
5. 加固清单(Server hardening 2026-05)
参考 memory project_server_2026_05_02_hardening:
- ✅ rpcbind / portmapper 关
- ✅ unattended-upgrades 自动安全补丁
- ✅ 5 个服务全部绑 127.0.0.1(外网扫不到端口)
- ✅ wcpp 停服(不用了)
- ✅ Caddy 仅放行 invest.* longsizhuo.com 和 mc.involutionhell.com
- ✅ ufw 仅开 22 / 80 / 443
- ⏳ TODO:Caddy CF IP 白名单(防绕 CF 直连源站)
6. systemd 服务清单
仓库 systemd/ 带三个 unit 模板(%h = 服务 User= 的 home,fork 用户只需改 User=/Group=,路径默认 ~/openInvest):
| unit | 起什么 | 端口 | 备注 |
|---|---|---|---|
invest-web.service | openinvest-web(FastAPI) | 127.0.0.1:8765 | REST,供 remote hub 转发 / 内部触发;走 openinvest-web 入口(带 token 守卫),别裸 uvicorn |
invest-mcp.service | openinvest-mcp --http(remote MCP) | 127.0.0.1:8766 | spoke agent 经 Caddy /mcp 直连;鉴权复用 INVEST_API_TOKEN |
invest-scheduler.service | python -m openinvest.scheduler.runner | 无 | 跑 jobs/*.yml 全部定时任务(event_watch / price_sentinel / pnl_snapshot / backup / fund_confirm / job_watchdog…);没有它 job 一个都不跑 |
# 三个一起装(User=ubuntu 先改成你的用户名;路径不是 ~/openInvest 的话改 INVEST_HOME 等 4 行)
sudo cp systemd/invest-{web,mcp,scheduler}.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now invest-web invest-mcp invest-scheduler
sudo systemctl list-units --type=service | grep invest升级包后三个都要手动 restart 才拉新代码:sudo systemctl restart invest-web invest-mcp invest-scheduler。 调度器的失败面(job 卡死 / 进程死亡)见下面 §6.1;详见 systemd/README.md 和 scheduler/README.md。
6.1 调度器防静默停摆:job 超时 + 外部心跳
2026-07-30 起 event_watch 因新闻抓取线程池 join 卡死 21 天:APScheduler max_instances=1 把之后每次触发都跳过,job_runs 一行没有。两层防线:
① 每 job 墙钟超时(jobs/*.yml 的 timeout_minutes,默认 30)
- runner 在 daemon 线程里跑 job,超时 →
job_runs.status='timeout'(附线程调用栈)→ 放弃该线程、释放max_instances=1槽位 → 下一次触发正常进入;job_watchdog把 timeout 当失败告警。 - 失败告警:
jobs/*.yml可选alert_on_failure: true(目前 backup / verdict_review / price_sentinel / fund_confirm)——最近一次已结束的运行是failed时看门狗发一次告警,按 (job, 当日) 去重;没标的 job 失败只落job_runs。 - 线程杀不掉,所以有上限:同一 job 累计 3 个超时线程未退出时 runner 拒绝再启动它,看门狗转报"停摆",
sudo systemctl restart invest-scheduler清空。 - 字段说明与各 job 默认值见
src/openinvest/jobs/README.md。改了要重启调度器。
② 外部心跳(dead-man switch,可选)
看门狗自己跑在调度器里——调度进程死了它也哑。配一个外部"死人开关"服务,调度器每 5 分钟(启动时先一次)GET 它的 ping URL;ping 断了由外部服务告警:
- 在 healthchecks.io(免费档够用)建一个 check:Period = 5 minutes,Grace = 10 minutes,通知渠道自选;复制它的 ping URL(
https://hc-ping.com/<uuid>)。 - 写进
.env(URL 是凭据,别提交进仓库):bash也可写INVEST_SCHEDULER_HEARTBEAT_URL=https://hc-ping.com/<uuid>core/config/defaults.yaml的scheduler.heartbeat_url(git clone 形态)。留空 = 不发。 sudo systemctl restart invest-scheduler,日志里没有[heartbeat] ping 失败即通;healthchecks.io 面板应在 1 分钟内变绿。
ping 失败只记 WARNING [heartbeat] ping 失败…,永远不影响调度。心跳本身是调度器线程池里的一个内部任务(不进 job_runs),所以进程死亡和线程池被全部占满都会让 ping 断掉。
6.2 memory/ 清理:*.bak.<时间戳> 与遗留 *.lock
memory/portfolio.md.bak.<ts>:openinvest init(含被"已有真实持仓"守卫拒绝的那次)/migrate_profile/migrate_portfolio_to_holdings覆盖前各留一份,从无清理(生产实测 247 份,其中 107 份内容逐字相同)。自动备份 job 排除*.bak.*,它们不在任何轮转里。*.lock:memory_store的 sidecar 锁文件。当前版本用后即删(取锁后核对 inode 再 unlink,并发安全);升级前遗留的(生产实测 158 个)用下面的命令清。
openinvest maintenance cleanup # 默认 dry-run:只打印将删除的数量,不动任何文件
openinvest maintenance cleanup --apply # 确认数量后真删
openinvest maintenance cleanup --keep 30 --days 14 # 调保留策略保留策略(宁多留):每个 family(如 portfolio.md)最新 20 份,或 mtime 在 7 天内的——并集保留,其余才删。*.lock 只删当前无人持有的(非阻塞 flock 探测,与正在跑的进程不抢锁)。该命令永远只操作本机数据目录,不会被 INVEST_API_BASE 转发走。
顺序:先升级并重启 web / mcp / scheduler 到带 inode 核对的新版本,再
--apply。旧版本进程取锁后不核对 inode,与清理动作的毫秒级时序重叠时理论上可能和新进程同时进临界区;先 dry-run 看数量,没问题再 apply。
7. 备份策略
权威状态散在两个 store——别只看 memory/
"钱"不是只在 memory/。无法重建的权威状态有两块,必须一起备份:
| 权威状态 | 是什么 | 丢了后果 |
|---|---|---|
memory/portfolio.md | 当前持仓 + 现金 | 丢掉"我现在持有什么" |
db/trades.db | 交易账本(planned→executed) | 丢掉 9 个月交易史 |
memory/{.committee,insights,daily,.dreams} | 历史决议 / 洞察 | 理论可重生,但要烧大量 LLM token |
.env | 凭据 + 配置 | 重新申请 / 填写 |
⚠️ 常见误区:以为
db/整个都是可丢的行情缓存。db/trades.db是账本不是缓存—— 照"db/ 不需备份"去迁移会丢掉整个交易历史。可丢的只是下面"不需要备份"列的那几个 db。
一键快照 / 迁移(推荐)
scripts/snapshot.py(开发仓脚本,不进 wheel——hub 上 clone 一份仓库或单拷这个 文件)把上面权威状态打成单个 tar.gz(WAL 安全的 sqlite online backup + sha256 校验),新机一条命令拉起。迁移 hub 到新机器就用它:
# 旧机:打包(不含 .env 密钥值,只在 manifest 列出要填哪些 key)
INVEST_HOME=~/openInvest uv run python -m scripts.snapshot snapshot --out ~/invest-snapshot.tar.gz
# 新机:装好 openinvest 后还原(默认拒绝覆盖已有账本,--force 才覆盖)
INVEST_HOME=~/openInvest uv run python -m scripts.snapshot restore --in ~/invest-snapshot.tar.gz异地备份(每周自动)
~/openinvest-research-archive/refresh.sh(cron 0 3 * * 0)每周把决议/洞察 **和账本(portfolio.md + trades.db,落 invest_ledger/)**推到私有 repo openinvest-research-archive。这是当前唯一的机外副本——别误删那条 cron。
每天自动本地备份(内置 job backup,默认开)
调度器每天 03:30(Asia/Shanghai)跑 jobs/backup.py:先对 db/ 下的 SQLite 库做 checkpoint(TRUNCATE)(把 WAL 里还没落盘的成交刷进主库——调度器长驻连接期间不会自己做; 进包的是 trades.db / insights.db / jobs.sqlite),再把 memory/(排除 *.lock / *.bak.*)+ 这三个库打成 $INVEST_HOME/.backups/auto_<UTC时间戳>.zip,只留最近 14 份 auto_*(手动备份不动)。 每日不含 memory/.backtest*,每周日(Asia/Shanghai)带上(舰队不可重跑):回测产物占整份备份约 89% (全量 271 MB vs 22 MB),但 .backtest/ 里混着 2026-07-24 起的前瞻纸面舰队,那部分重跑不出来, 所以周日那份整份带上,文件名加 _full 后缀(auto_<stamp>_full.zip),共用同一个 14 份轮转,14 天里自然留 2 份。 不备 market_data.db / events.db(可重拉的缓存)与 .env。失败抛异常 → job_runs 记 failed;backup.yml 标了 alert_on_failure: true,job_watchdog 据此告警(同一天只报一次)。 zip 内路径相对数据根目录,恢复用 plugin/skills/invest-backup 的 run.sh restore <zip>。 这是机内冷备,不替代上面的每周异地备份。
每天本地冷备(tar.gz,可选)
# 每天 cron——直接复用 snapshot.py,一份 tar 含全部权威状态
0 4 * * * cd $HOME/repos/openInvest && INVEST_HOME=$HOME/openInvest $HOME/.local/bin/uv run python -m scripts.snapshot snapshot --out /backup/invest-$(date +\%F).tar.gzCloudflare Access 配置
CF Dashboard 端的 Access policy 没有 git 备份,建议手动截图保存策略 JSON。
不需要备份(新机首跑自动重建)
db/market_data.db/db/events.db(行情 + 新闻缓存,可重新拉)db/chroma.sqlite3/db/jobs.sqlite(向量库 / 调度器 job store,可重建)cache_data/(HTTP cache)memory/.backtest*(回测研究产物,非账本;例外:.backtest/里 2026-07-24 起的前瞻纸面舰队重跑不出来,内置backupjob 每周日的_full备份会带上)
8. 监控(可选)
后端日志:
sudo journalctl -u invest-web -f --since "1 hour ago"CF Access 日志:CF Zero Trust → Logs → Access。
数据源健康:uvx openinvest doctor(或 hub 上 curl 127.0.0.1:8765/api/health)。
9. 多设备:hub-and-spoke 远端模式(2026-06)
一台机器(hub)持有唯一的 memory/ 并跑 web_api;其他设备(笔记本/另一台 开发机)的 CLI / Claude Code skill 设 INVEST_API_BASE 后所有子命令转发到 hub,读写都走 HTTP——锁仍是 hub 单机 fcntl,零分布式复杂度。中央调参 自动成立:strategy / 角色 prompt 都在 hub,改一处全设备生效。
推荐新路径:remote MCP(BETA,2026-07,REST 退役路线 A)
⚠️ BETA:作者本人未在真实多设备环境测试过(仅自动化测试 + hub 本机 curl 端到端)。已知问题与求助清单见 issue(HELP WANTED)。
hub 常驻 openinvest-mcp --http(streamable-HTTP,绑 127.0.0.1:8766, systemd/invest-mcp.service),spoke 机器的 agent 直连 MCP,不再经 CLI→REST 转发。全部工具(读/写/委员会 Direct)均可用,鉴权复用同一 INVEST_API_TOKEN(bearer,/health 豁免):
# spoke 侧注册(Claude Code;其他 MCP client 同理配 url + header)
claude mcp add --transport http openinvest https://invest.your-domain.com/mcp \
--header "Authorization: Bearer $INVEST_API_TOKEN"
# CF Access Service Token 场景再加:
# --header "CF-Access-Client-Id: xxx.access" --header "CF-Access-Client-Secret: yyy"hub 的 Caddy 加一条路由(后端 Host 校验:token 模式默认关闭——反代下游 Host 是公网域名,token 才是信任边界;要收紧可设 INVEST_MCP_ALLOWED_HOSTS=invest.your-domain.com 白名单):
handle /mcp {
reverse_proxy 127.0.0.1:8766 {
flush_interval -1
}
}nginx 等反代需要关闭响应缓冲,让 SSE 进度和心跳及时到达客户端:
location = /mcp {
proxy_pass http://127.0.0.1:8766;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 300s;
}只靠 CF Access、不设 INVEST_API_TOKEN(Access-only)时必须设 INVEST_MCP_ALLOWED_HOSTS:无 token 时后端保留 MCP SDK 默认的 loopback Host 白名单(127.0.0.1 / localhost / [::1]),反代转发来的公网域名 Host 会直接 被回 421 Invalid Host header。在 hub 的 .env 写上对外域名(逗号分隔, 条目支持 host:* 通配端口):
INVEST_MCP_ALLOWED_HOSTS=invest.your-domain.comAccess Service Token 和应用 Bearer token 是两层独立鉴权,组合使用时客户端必须 同时发送上文的三项 header。
与 REST 转发的关系:CLI→REST 转发(下文 INVEST_API_BASE)进入维护模式, 仍支持但不再演进——它还覆盖 remote MCP 没有的 event_check (Coordinator 协议 2026-10-18 起已有 MCP 工具 prepare_committee / submit_committee)(doctor 已有 MCP 工具);日常读写/Direct 委员会请优先 remote MCP。HTTP 使用 stateless SSE:run_committee 在同一请求中返回最终结果, 期间发送 SDK 心跳,并在客户端提供 progress token 时推送阶段进度。同步工具的 阻塞 IO 在线程池执行,慢查询不会占住事件循环、阻塞其他客户端或心跳。 自写脚本直连 /mcp 时,POST 的 Accept 头必须同时列出 application/json 和 text/event-stream,只写 application/json 会得到 406(官方 SDK、Claude Code、Codex 都已这样发)。
已知限制:心跳不能延长客户端设置的总调用期限,也不能保证所有代理配置都允许 长请求;仍需按真实部署验证 Cloudflare/Access 链路。Cloudflare 的实际限额以 官方 524 文档 为准。stateless 模式不提供断线续传;调用中断后应先查状态/当日委员会缓存, 不要自动重放买卖、入金等非幂等写操作。长任务也可继续由 hub 侧 cron/REST 轮询路径跑。 自动部署(invest-deploy.sh)的 restart 行记得加 invest-mcp.service。
10. 顾问模式(INVEST_ADVISORY_MODE)
适用场景:你想在群聊里部署一个"投资顾问版艾露猫",让其他人可以问 "XXX 能不能买"、"怎么看黄金",但不暴露你的真实持仓,也不能买卖操作。
设置 INVEST_ADVISORY_MODE=1 环境变量即可开启:
# 启动 MCP 时注入
INVEST_ADVISORY_MODE=1 uvx openinvest mcp顾问模式行为变化
顾问模式白名单只放行委员会分析必需的工具 + 新闻源管理(只动本实例自己的 INVEST_HOME/rss_feeds.yml),其余一律拒绝(包括事件入库 ingest_event)(mcp_server.py 的 ADVISORY_ALLOWED_TOOLS;改动需求见 test_mcp_server.py::test_advisory_mode_gate_is_closed_set 的机器强制契约,白名单外新增工具漏加闸会直接 CI 红):
| 工具 | 正常模式 | 顾问模式 |
|---|---|---|
run_committee | ✅ 任意标的 | ✅ 任意标的 |
explain_decision | ✅ | ✅ |
live_prices | ✅ | ✅ |
ingest_event | ✅ 入库 + 命中持仓时按频控触发委员会 | ❌ 不可用(入库内容会被后续顾问分析召回) |
news_sources / add_news_source / remove_news_source | ✅ | ✅ 管理本实例自己的额外源清单(仅公网 http(s) 地址、直连不走 HTTPS_PROXY + probe 校验 + 上限 30,群聊自助喂源用) |
what_if | ✅ | ❌ 不可用(本质是读真实持仓做假设推演,会泄露仓位/浮盈) |
rebalance_plan | ✅ | ❌ 不可用(对照真实持仓算该买卖多少,会泄露仓位) |
record_execution | ✅ | ❌ 不可用(写真实决策账本,顾问模式下无合法用途) |
status / strategy / history / discipline / decisions / doctor | ✅ | ❌ 不可用 |
buy / sell / record_existing_position | ✅ 记账 | ❌ 不可用 |
deposit / withdraw | ✅ 记账 | ❌ 不可用 |
record_dividend / record_fee / record_split / record_return_of_capital | ✅ 记账(分红·利息 / 费用·税 / 拆股 / 退回本金,ADR-027) | ❌ 不可用 |
place_fund_order / confirm_fund_order / cancel_fund_order | ✅ 记账(场外基金两段式:下单扣现金记在途 / 按净值确认 / 撤销,ADR-027);fund_confirm job 每个工作日晚自动确认 | ❌ 不可用 |
set_allocations | ✅ | ❌ 不可用 |
track_asset / untrack_asset | ✅ | ❌ 不可用 |
委员会分析仍然完整运行(Macro / Quant / Risk / CIO 四角色),但:
portfolio_summary显示为顾问模式占位文案,不含任何真实持仓数据;- Dreaming 长期洞察(
prior_insights)不注入 prompt; - 委员会跑完不落盘到你的真实 memory / 决策账本(
persist_to_memory=False), 群聊查询不会污染你自己的history/decisions/ path_review 命中率统计。
已知残留风险:explain_decision 允许查看历史委员会 verdict——如果 decision_id 对应的分析是在开启顾问模式之前(也就是正常模式、真实持仓上下文 下)跑出来的,那份历史 transcript 本身就含真实 portfolio_summary。顾问模式没有 (也无法)事后过滤已落盘的历史文件。真正的隔离需要下面这条部署建议里的独立 INVEST_HOME。
另外,如果你给顾问实例的 INVEST_HOME 配了定时抓取,群聊里加的额外新闻源内容会 进入之后的顾问分析;所以顾问实例永远不要和你自己的真实实例共用 INVEST_HOME。
部署建议
强烈建议顾问实例用独立 INVEST_HOME(独立 memory/ 目录,跑 invest-setup skill 或手动建一份空持仓即可), 而不是复用你自己账户的 INVEST_HOME——这样即使 explain_decision 被问到某个 decision_id,读到的也是顾问实例自己积累的历史,不会是你的真实持仓分析。
日常聊天场景(Hermes 等按 config 自己 spawn 子进程的 client)直接声明 stdio server,不需要手动起终端:
# ~/.hermes/config.yaml —— 与主实例并行注册一个顾问命名空间
mcp_servers:
openinvest-advisor:
command: uvx
args: ["openinvest", "mcp"]
env:
INVEST_HOME: ~/openinvest-advisor # 独立顾问实例的 memory 目录
INVEST_ADVISORY_MODE: "1"如果是给群聊 bot 后端复用(多个用户共享同一个常驻顾问实例,或跨机器连),按第 9 节的 remote MCP 方式起 HTTP 常驻服务再注册:
INVEST_HOME=~/openinvest-advisor INVEST_ADVISORY_MODE=1 \
uvx openinvest mcp --http --port 8767
# client 侧按 remote MCP 一节的方式注册(以 Claude Code 为例):
claude mcp add --transport http openinvest-advisor http://127.0.0.1:8767/mcp对应的 agent prompt 模板见 20-agent-usage-tutorial.md。
笔记本 (client) hub(本机/VPS)
run.sh status ──HTTP──┐ invest-web.service :8765
run.sh buy ... ├──────────────▶ ├─ /api/skill/* (CLI 等价端点)
prepare_committee ────┘ ├─ /api/committee/* (prepare/save/run)
(本机零 memory/) └─ memory/ ← 唯一账本,fcntl 锁hub 侧(已有部署零必改)
走既有 Caddy + CF Access 的部署什么都不用动——客户端用 CF Access Service Token 过边缘即可(推荐,见下)。没有 CF 的局域网场景才需要:
# .env
INVEST_API_TOKEN=<openssl rand -hex 24> # 开应用层鉴权
INVEST_WEB_HOST=0.0.0.0 # 绑出 loopback(局域网直连时)token 语义:**所有来源(含 loopback)**访问 /api/*(/api/health 豁免) 及 /docs /openapi.json /redoc 都要求 Authorization: Bearer;不设 token 行为完全不变。(原 loopback 豁免已删——反代下连接源恒为 127.0.0.1,豁免即裸奔; token 模式下浏览器裸开 /docs 会 401,用 curl -H 或反代注入 header。)
客户端侧(2 分钟)
# 装 skill(run.sh 首跑自动从 PyPI 拉 openinvest),然后 $INVEST_HOME/.env 只要:
INVEST_API_BASE=https://invest.your-domain.com # 或 http://10.0.0.x:8765
INVEST_API_TOKEN=... # hub 开了才需要
~/.claude/skills/invest/scripts/run.sh doctor # 验证:status ready + remote 段客户端没有 memory/、不需要 DeepSeek key / Gmail 凭据。init 在远端 模式下被禁用;run_committee 在 hub 上跑(CLI 自动轮询);live_prices / correlate 仍本地跑。写操作落 hub 账本,history 记 source: skill_remote。 buy --existing-position(补录已持有仓位、不扣现金)不走 REST 转发——/api/skill/buy 没有这个字段,客户端直接报错而不是让 hub 扣现金;去 hub 上跑,或用 remote MCP 的 record_existing_position 工具(hub 版本没有这个工具时回 Unknown tool,不会扣现金)。
推荐:Cloudflare Tunnel + Access Service Token(hub 不开公网端口)
- CF Zero Trust → Access → Service Auth → 创建 Service Token,拿到 Client ID / Secret
- Access Application(invest 域名)的 Policy 加一条
Service Authinclude - 客户端 .env:bashremote dispatch 会自动带上
INVEST_API_BASE=https://invest.your-domain.com CF_ACCESS_CLIENT_ID=xxx.access CF_ACCESS_CLIENT_SECRET=yyyCF-Access-Client-Id/Secret头;浏览器用户 照旧走 SSO。hub 继续只绑 127.0.0.1,后端零改动。
已知限制
- 委员会跑在独立 daemon 线程(#105),其余阻塞端点(event_watch 扫描、 committee prepare、yfinance / IMAP 读、sqlite)由 FastAPI 线程池执行 (#233-3),不再冻结事件循环,单 worker 即可;委员会长任务走"触发 + 轮询", 不会撞 CF ~100s 代理超时。
- 客户端与 hub 的"同日 cache"以 hub 的日期为准(取自
/api/health时间戳),跨时区设备不会错位。
下一步
→ 09-troubleshooting.md — 部署后跑挂了去哪查
→ adr/ — 为什么这套部署模式(不上 SSR / 不上 K8s)