Skip to main content
Glama
JingxuanC

Factor Miner MCP

README.md
# Factor Miner MCP

A 股量化因子挖掘工具集的独立 MCP(Model Context Protocol)服务。从
[Athena](https://github.com/JingxuanC/Athena) 的 py-sidecar 中抽取
factor 域,让任何 MCP 客户端(Claude Desktop、Kimi Code、Cursor、自研
Agent)都能直接驱动完整的「因子挖掘 → 评估 → 回测 → 上线巡检」流水线。

## 工具清单(17 个)

**因子挖掘**

| 工具 | 说明 | 负载 |
|------|------|------|
| `factor_execute` | 沙箱执行 factor.py(import 白名单 + rlimit + 120s 超时),跑评估电池 | 重(异步) |
| `factor_backtest` | 全量 qlib 回测:SOTA 因子 + 新因子对齐 Alpha20 baseline,qrun 出指标(可交给独立执行器跑,见下文) | 重(异步) |
| `factor_oos_check` | 生产准入 OOS 检查:挖掘窗口 vs 纯样本外窗口,报告 IC/年化/回撤 + 衰减(同上,可外置) | 重(异步) |
| `factor_daily_compute` | 每日收盘后计算在线因子,写 Redis `dfactor:{symbol}`(TTL 48h) | 重(异步) |
| `update_data` | qlib cn_data 每日增量更新(三源熔断 + 原子切换)+ 重建 h5 数据集 | 重(异步) |
| **`factor_evaluate`** | **一段因子代码 → 专业评估(一条链)**:沙箱跑代码 + 同窗口切片对齐的 alphalens 口径 tearsheet,附**单调性 / IC 半衰期 / IC t 值 / 假设缺失标记**。补上 `factor_execute`(只有契约检查)与 `factor_tearsheet`(要自带 factor_values+klines)之间的断链 | 重(异步) |
| `factor_recent_ic` | 衰减巡检:近 N 交易日截面 IC(纯 pandas,无需 qlib) | 轻 |
| `compute_factors` | 从 OHLCV K线计算 Alpha158 风格因子(纯 pandas) | 轻 |
| `predict` | 因子值 → ML 信号预测 | 轻 |

**ML**

| 工具 | 说明 | 负载 |
|------|------|------|
| `ml_train_rolling` | 滚动训练:K线 → Alpha158 因子 + 次日收益标签 → 扩张窗训练,输出 IC/RankIC/Sharpe。`model="lgbm"`(默认)走 LGBM;`model="master"` 走 MASTER 深度学习后端 | 重(异步) |
| `ml_predict` | 用滚动模型出次日收益预测(lgbm 优先 Redis 因子快照;master 用最近 seq_len 天特征序列) | 轻 |
| `ml_metrics` | 训练器状态:最近训练日、逐日指标、模型类型(lgbm/master)、特征清单 | 轻 |

### MASTER 深度学习后端(`model="master"`)

[MASTER](https://github.com/SJTU-DMTai/MASTER)(AAAI 2024,微软 qlib
benchmark 收录)是股票专用 Transformer:日内时序注意力(TAttention)+
截面股票间注意力(SAttention)+ market-guided gating(用市场上下文门控
个股特征)。本实现复用与 LGBM 相同的 point-in-time Alpha158 因子(无未来
函数),batch 为「同一交易日的全部股票截面」,市场上下文取当日截面特征的
mean/std。网络结构 vendor 自官方仓库(`factor_miner/master_nn.py`,MIT)。

```json
// 训练(异步 job,返回 job_id 后用 job_status 轮询)
{"name": "ml_train_rolling", "arguments": {
  "model": "master", "klines_list": [...], "seq_len": 8, "epochs": 3}}
// 预测 / 状态
{"name": "ml_predict", "arguments": {"model": "master", "klines_list": [...]}}
{"name": "ml_metrics", "arguments": {}}
```

注意事项:

- **截面模型**:至少 4 只有效股票才训练(建议 ≥10 只);`max_symbols`
  默认 50 上限校验(CPU 保护)。
- **CPU 默认值偏小**:`seq_len=8` / `epochs=3` / `d_model=64`,
  `torch.set_num_threads(2)`。2 核 / 3G 内存服务器上,20 股 × 2 年
  量级约几分钟(瓶颈在逐日因子重算,与 LGBM 路径相同);加大 epochs
  或股票数会线性变慢,训练全程走异步 job 不占 HTTP 连接。
- 特征归一化统计量只用训练段,模型 + 归一化器一起存
  `FACTOR_MINER_MODEL_DIR/master.pt`。
- 需要 torch(CPU 版,Docker 镜像已内置;本地 `pip install torch`)。

**因子评估与组合**

| 工具 | 说明 | 负载 |
|------|------|------|
| `factor_tearsheet` | Alphalens 式因子完整评估:分位数组收益、多空价差、IC 序列(均值/IR/衰减)、换手率(手写 pandas) | 轻 |
| `portfolio_optimize` | 组合优化:HRP / 等权 / 最小方差内置;装 pypfopt 后支持 `mean_variance`(max Sharpe) | 轻 |
| `regime_detect` | 牛/熊/震荡识别:规则状态机(动量 + 已实现波动率阈值)内置;装 hmmlearn 走 GaussianHMM | 轻 |
| `change_point` | 结构突变检测:CUSUM + 二分分割(水平 + 漂移两路)内置;装 ruptures 走 PELT | 轻 |
| `vol_forecast` | 波动率预测:EWMA(λ=0.94)+ Parkinson 高低价参考内置;装 arch 走 GARCH(1,1) | 轻 |

重负载工具提交即入队返回 `job_id`,轮询 `GET /jobs/<id>` 拿结果,
不占 HTTP 连接。

### `factor_evaluate`:从「一段代码」到「专业指标」的一条链

`factor_execute` 只回契约检查(`eval_ok` / `eval_detail`),**不回因子值**;
`factor_tearsheet` 要调用方自带 `factor_values` + `klines`。两者之间本来是断的
——任何 agent 都拿不到「代码 → 分层收益 / IC / 换手」。`factor_evaluate` 接上它:

```
factor_evaluate(code, hypothesis?) 
  = 沙箱跑 code(_run_factor_window:白名单 + rlimit + 120s)
  + 同一次窗口切片给的 close(因子值与价格天然对齐,不做事后 intersect)
  + analytics.factor_tearsheet(alphalens 口径,原样透传)
  + 专业摘要:严格单调性(+rho) / IC 半衰期(决定持有期) / IC t 值 / 假设缺失标记
```

实测(真实 `daily_pv_all.h5`,333MB,4809 标的,window_days=120):

| 阶段 | 耗时 |
|---|---|
| `stage_h5_for_sandbox`(Fixed 格式只能整读,截窗 + 写盘) | 117.7s |
| 沙箱跑因子代码 | 27.2s |
| 读回 result.h5 + 窗口 close | 2.9s |
| 拼面板 | 12.3s |
| tearsheet | 18.6s |

**所以它必须走异步队列**(`server.py` 的 `ASYNC_TOOLS`)——一次评估是分钟级,
不是秒级。整读全量的开销与 `factor_execute` / `factor_recent_ic` 同源,
不是本工具引入的。

### 实现说明与算法出处

- `factor_tearsheet` **手写 pandas 而非依赖 alphalens 本体**:alphalens
  已半停维护(上游多年无实质更新),其依赖链与 pandas>=2 冲突频发;
  分位数收益 / IC / 换手率逻辑本身很短,手写可控、可测、零额外依赖。
- HRP — López de Prado (2016), *Building Diversified Portfolios that
  Outperform Out-of-Sample*(相关距离 → 层次聚类 → 拟对角化 → 递归二分)。
- EWMA — RiskMetrics (1996), J.P. Morgan Technical Document,λ=0.94,
  多期预测平坦外推。
- PELT — Killick et al. (2012), *Optimal Detection of Changepoints With
  a Linear Computational Cost*, JASA(可选增强,内置为 CUSUM(Page 1954)
  + 二分分割)。
- HMM — GaussianHMM(收益率 + 滚动波动率两特征,可选增强,内置为规则
  状态机)。
- 全部 5 个工具:**纯 numpy/pandas/scipy 路径开箱可用**,重库
  (pypfopt / hmmlearn / ruptures / arch)惰性导入做可选增强,输出
  `method` 字段标注实际实现。

## 快速开始

```bash
pip install -r requirements.txt
python3 server.py --port 50053
```

验证:

```bash
curl http://127.0.0.1:50053/health
curl http://127.0.0.1:50053/tools
```

接入 MCP 客户端(以 Claude Desktop / Kimi Code 为例):

```yaml
# mcp 配置
factor:
  url: http://127.0.0.1:50053/mcp
```

## Docker 部署

无需本地 Python 环境,一条命令起服务:

```bash
docker compose up -d        # 构建镜像 + 启动容器(首次构建约 3-5 分钟)
docker compose ps           # 查看状态
docker compose logs -f      # 跟踪日志
```

验证:

```bash
curl http://127.0.0.1:50053/health
curl http://127.0.0.1:50053/tools   # 应返回 16 个工具
```

说明与限制:

- `pyqlib` 按构建架构自动处理(Dockerfile `WITH_QLIB` build-arg,默认 `auto`):
  - **amd64**(云服务器常见架构):自动安装官方 manylinux wheel,
    `factor_backtest` / `gen_data` / `update_data` 开箱即用;
  - **aarch64**(Apple Silicon / ARM 服务器):PyPI 无 linux/aarch64 wheel,
    默认跳过,回测/数据工具返回明确错误,其余 12 个工具不受影响。
    需要时用源码编译构建:`docker build --build-arg WITH_QLIB=1`
    (慢,约 10-20 分钟,构建期需能访问 GitHub;拉不动可换镜像:
    `--build-arg QLIB_GIT_URL=https://gitee.com/mirrors/qlib.git`);
    或直接用 `docker run --platform linux/amd64` 跑 amd64 镜像
    (QEMU 模拟,慢但能用)。
  - 完全禁用:`--build-arg WITH_QLIB=0`。
  国内构建加速:`--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple`。
- h5 数据集可通过 volume 挂载:
  `- ./data:/app/data` 并设 `FACTOR_MINER_DATA_DIR=/app/data/factor_mining`。
- `lightgbm` 已随镜像安装(镜像内含 libgomp1 OpenMP 运行时),
  `ml_train_rolling` / `ml_predict` 可用。训练出的模型默认落在容器
  `/tmp/athena_models/`(重建即丢),生产部署请挂卷并设
  `FACTOR_MINER_MODEL_DIR=/app/models`。
- `torch`(CPU 版)已随镜像安装(MASTER 深度学习后端):离线/代理环境
  可把预下载的 torch CPU wheel 放进 `wheels/`(文件名 `torch-*.whl`)
  构建期离线安装;否则按 `PIP_INDEX_URL` / 官方 CPU 源在线装。
  注意镜像体积因此增加约 300-800MB(视 torch 版本),3G 内存服务器
  训练 MASTER 时建议股票数 ≤50、epochs ≤5,避免与 qlib 回测类重任务
  并发(队列 worker 数可用 `MCP_WORKERS=1` 压低)。
- Redis 缓存(`dfactor:*` 写入)可选:设置 `REDIS_URL` 指向可达的
  Redis,缺失时自动降级跳过缓存写入。

license 鉴权(可选):在 `docker-compose.yml` 中取消注释,把宿主机
`licenses.json` 挂进容器并设置 `MCP_LICENSE_FILE`:

```yaml
environment:
  MCP_LICENSE_FILE: /app/licenses/licenses.json
volumes:
  - ./licenses.json:/app/licenses/licenses.json:ro
```

## 数据准备(回测类工具需要)

`factor_execute` / `factor_evaluate` / `factor_backtest` / `factor_oos_check` 依赖 qlib
cn_data 导出的日频量价 h5 数据集:

```bash
pip install pyqlib  # arm64 Linux 需从 GitHub 源码编译,见 requirements.txt 注释
python3 -m factor_miner.update_data          # qlib cn_data 增量更新
python3 -m factor_miner.gen_data --debug     # 调试集(100 股 × 2 年)
python3 -m factor_miner.gen_data --full      # 全量(回测用)
```

`factor_recent_ic` / `compute_factors` / `predict` 纯 pandas 实现,
不依赖 qlib,开箱即用。

## 把 qrun 拆出去跑(`factor_executor/`,可选)

`qrun` 是全市场 LGBM 训练 + 组合回测,**内存是 GB 级**;而 miner 容器通常被限制在
1.5 GiB 左右。两者塞在同一个 cgroup 里的后果实测过:

- qrun 撞顶 → **内核 memcg 把整个 miner 容器杀掉**(不是只杀那个任务)→
  `unless-stopped` 重启 → 客户端看到 `RemoteProtocolError`
- 失败的 qrun 会变成**孤儿进程继续占内存**,把容器卡在 1522/1536 MiB,
  此后**任何**请求都必然 OOM,只能重启清场

`factor_executor/` 把**只有第 4 步**(执行 + 解析)搬到一个独立进程/容器:

```
miner 容器(轻)                          executor 容器(重)
  Step 1 沙箱跑因子                          qrun(自有 cgroup 上限)
  Step 2 去重闸门(日频 IC)        ──►      LGBM 训练 + 组合回测
  Step 3 拼 combined_factors_df.h5          解析 mlruns → metrics
  Step 4 交给执行器(若已配置)
```

Step 1–3 留在本地,因为它们本来就轻、且依赖面板归一化与 exec_cache。

**开启方式**:miner 侧设 `FACTOR_EXECUTOR_URL`,空 = 继续本地执行(默认,行为不变)。

**共享文件系统是硬前提**:请求里传的是**路径**不是文件内容(几十 MB 的面板不适合
走 HTTP 请求体),所以 miner 与执行器必须把宿主机上的 backtest 根目录挂到**同一个
容器路径**(默认 `/work`)。不一致时提交会被明确拒绝,而不是等 qrun 报一个看不懂的错。

**qlib 数据必须挂祖父目录**:日更的 atomic_swap 会整体 rename 替换 `cn_data`,
挂更深的子目录会在一次日更后指向已删除的 inode。所以挂 `qlib_data` 这一层:

```yaml
# compose 片段(执行器)
factor-executor:
  build: {context: ./factor-miner-mcp}
  command: ["python3", "-m", "factor_executor.server"]
  environment:
    EXECUTOR_JOB_ROOT: /work
    EXECUTOR_QLIB_ROOT: /qlib_data
    EXECUTOR_PANEL_H5: /data/factor_mining/daily_pv_all.h5
    EXECUTOR_MEM_LIMIT_MB: "7168"    # qrun 子进程的 RLIMIT_AS(见下方实测)
    EXECUTOR_TIMEOUT: "1800"
  volumes:
    - ./backtests:/work                       # 与 miner 同挂同一宿主目录
    - /opt/athena-mcp/qlib_data:/qlib_data:ro # ⚠️ 祖父目录
    - /opt/athena-mcp/data/factor_mining:/data/factor_mining:ro
```

miner 侧对应加 `FACTOR_EXECUTOR_URL=http://factor-executor:50054`,并把
`<backtests>:/work` 也挂上。

**数据版本会校验**:请求带 miner 侧的面板版本(manifest 的 `h5_sha256` 优先),
执行器比对不一致就**拒绝执行** —— 面板与执行器 qlib 数据不同源时回测会静默偏掉,
那比失败糟糕得多。

**失败隔离**:每个 job 一个独立会话/进程组,超时或失败都按**组**收割(`killpg`),
不会再留孤儿;`-9` 会被翻译成"很可能是内存不足"的提示。执行器崩了不影响 miner
的其它工具。

### qrun 要多少内存(生产实测,别再按猜的值配)

`factor_oos_check` 走 full profile = **`market: csi300` + `start_time 2008` + `test_end null`**,
也就是全量 csi300 跨十余年的 Alpha158 特征 + LGBM 训练 + 组合回测。在这台机器
(7.4G 总内存)上实测:

| 配置 | 结果 |
|---|---|
| `RLIMIT_AS = 5120 MiB` | **失败** —— qrun 90s 后 `MemoryError: Unable to allocate 80.0 MiB`;申请 80MiB 都失败说明是**地址空间**耗尽,不是机器没内存 |
| `RLIMIT_AS` 不限,容器 10 GiB | 成功,cgroup 峰值 **3783 MiB** |
| `RLIMIT_AS = 7168 MiB` + 容器 `mem_limit: 8192m` | **成功**,cgroup 峰值 **3581 MiB**(留约 2× 余量) |

**要注意的是虚拟地址空间,不是 RSS**:5120 MiB 的 RAS 都过不去,而实际 RSS 只有
~3.6 GiB —— qlib/pandas 的 mmap 与中间对象让 VA 明显高于常驻。所以这个上限不能照着
"RSS × 1.2" 去设。

`mem_limit` 与 `RLIMIT_AS` 刻意都设、且 `RLIMIT_AS < mem_limit`:前者是容器护栏,
后者让 qrun 先于容器被自己杀掉(不会拖累执行器进程本身)。

相关测试:`test_factor_executor.py`(用假 qrun 驱动,不需要 pyqlib,本机可跑)。

## 每日数据同步(update_data)

`update_data` 已封装为 MCP 工具(异步 job):交易日历对齐 → 三源熔断抓取
(mootdx → 腾讯 → 东财)→ raw+qfq 复权对齐 → staging 校验 → 原子切换 →
自动重建 `daily_pv_all.h5`。任一环节失败保留旧数据,退出码非 0。

部署要点:

- **持久化**:qlib cn_data 必须挂卷(compose 里 `./qlib_data:/app/qlib_data` +
  `QLIB_PROVIDER_URI=/app/qlib_data/cn_data`),否则容器重建数据就没了;
  h5 数据集挂 `./data:/app/data`。
- **冷启动**:增量更新从既有 cn_data 日历尾部续抓,首次需要一份基础数据
  (从现有 Athena 部署拷贝 `~/.qlib/qlib_data/cn_data`,或 qlib 社区 dump),
  之后每日只增量。
- **定时调度**:宿主机 crontab 每个交易日收盘后调一次(非交易日自动空转):

```cron
# 周一到周五 15:40 / 18:10 各跑一轮(收盘后数据落定有延迟,双轮兜底)
40 15 * * 1-5 curl -s -X POST http://127.0.0.1:50053/mcp -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"update_data","arguments":{}}}'
10 18 * * 1-5 curl -s -X POST http://127.0.0.1:50053/mcp -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"update_data","arguments":{}}}'
```

返回 `job_id`,轮询 `GET /jobs/<id>` 拿结果;也可用 CLI:
`docker exec factor-miner-mcp python3 -m factor_miner.update_data --limit 10`(冒烟)。

## 鉴权与额度(可选)

默认开放模式(本地/内网)。设置环境变量后强制 license key 鉴权:

```bash
export MCP_LICENSE_FILE=/path/to/licenses.json
python3 server.py --port 50053
# 客户端请求头:X-License-Key: <key>
```

license JSON 格式与额度语义见 `mcp_gateway.py`  docstring。`GET /quota`
查余量,`GET /queue-stats` 看队列。

## 端点一览

```
GET  /health        健康检查
GET  /tools         工具 JSON schema 列表
POST /mcp           MCP JSON-RPC(initialize / tools/list / tools/call)
GET  /jobs/<id>     异步任务状态/结果
GET  /quota         license 额度余量(鉴权模式)
GET  /queue-stats   队列概况
GET  /metrics       Prometheus 指标(无需鉴权)
```

## 可观察性 / Observability

`GET /metrics` 输出 Prometheus text exposition 格式(无需鉴权,仅工具名级聚合):

- `mcp_tool_calls_total{tool,status}` — 调用计数,status ∈ ok/error/rejected_license/rejected_quota/queued
- `mcp_tool_latency_seconds_sum{tool}` / `mcp_tool_latency_seconds_count{tool}` — 延迟累计/次数(异步任务从入队到完成)
- `mcp_license_check_total{result}` — license 校验计数(ok/invalid)
- `mcp_uptime_seconds` — 进程启动至今秒数
- `mcp_queue_depth` — 当前排队任务数(gauge)
- `mcp_queue_jobs_total{status}` — 异步任务完成计数(done/error)

Prometheus 抓取配置示例:

```yaml
scrape_configs:
  - job_name: factor-miner-mcp
    metrics_path: /metrics
    static_configs:
      - targets: ["127.0.0.1:50053"]
```

## 沙箱安全模型

`factor_execute` / `factor_evaluate` 等执行用户提交的 factor.py 时在子进程沙箱中运行:
AST import 白名单(仅 pandas/numpy 等)、rlimit 资源限制、120s 超时、
隔离工作目录。实现见 `factor_miner/sandbox.py`。

## 致谢

- `factor_miner/gen_data.py` 移植自 microsoft/RD-Agent(MIT)
- `factor_miner/qlib_dump_bin.py` 裁剪自 microsoft/qlib v0.9.6(MIT)
- `factor_miner/master_nn.py` vendor 自 SJTU-DMTai/MASTER(AAAI 2024,MIT)的
  qlib 提交版(qlib 0.9.7 的 contrib 未合入 MASTER,故本地内置)
- 本项目主体来自 [Athena](https://github.com/JingxuanC/Athena)

## License

MIT