quantai-service

Installation
SKILL.md

QuantAI Service — Agent 操作手册

服务地址

BASE_URL = http://54.151.204.72:8000

启动前用 curl ${BASE_URL}/health 确认服务在线,若连接失败立刻告知用户。


⛔ 严禁行为

  1. 禁止在本地运行任何 quant-factor-loop 脚本run_workflow.pystep*.py 等),所有计算都在服务器上。
  2. 禁止下载或存储 parquet 数据文件,不要尝试访问服务器上的数据文件。
  3. 禁止修改 /home/ec2-user/quant-factor-loop/ 下的任何文件,该目录只属于服务器内部。
  4. 轮询时禁止无限等待:单个 Job 最长等待 30 分钟,超时后告知用户并停止。
  5. 禁止跳过 retest 步骤:Step 4C 的 C# 编译失败时必须修复 strategy.cs 后 retest,不能直接宣告失败。
  6. 禁止等待 Step 5 及之后的步骤:Step 4C 完成后立即获取结果,不再轮询。Step 5-16 是服务端内部调优,与用户无关。

Agent 本地约定路径

每次开始新任务时,将以下路径记录到工作变量中:

./quant_agent/
└── jobs/
    └── {job_id}/
        ├── plugin.py               ← 提交时上传的因子插件(阶段2完成后保存)
        ├── strategy.cs             ← Step 4a 生成的 C# 策略(strategy_cs_ready=true 后下载)
        ├── factor_card_default.json← Step 4C 默认参数因子档案卡(Step 4C 完成后下载)
        └── step4c/
            ├── equity_curves.png   ← Step 4C 默认参数权益曲线图(Step 4C 完成后下载)
            ├── trade_log.csv       ← Step 4C 默认参数交易记录(Step 4C 完成后下载)
            ├── group_return_plot.png ← CS 分组累计收益图(Step 4C 完成后下载,可能不存在)
            ├── cs_profile_4panel.png ← CS 4合1截面评价图(Step 4C 完成后下载,可能不存在)
            └── cs_nav_curves.png    ← CS 净值曲线图:多头/空头/多空(Step 4C 完成后下载,可能不存在)

说明:服务器内部会跑两次云端回测——Step 4C(默认参数)和 Step 11(调优参数)。 Agent 只下载并展示给用户默认参数版default_ 前缀文件),调优参数版留在服务端。


服务器内部流程(Agent 无需操作,仅供了解)

提交任务后,服务器自动执行以下步骤,Agent 全程只需轮询等待

Step 1-3   加载配置、计算前向收益、设置退出规则
Step 4A    生成 C# 策略代码(strategy.cs)
Step 4B    计算原始信号(Python 研究镜像)
Step 4L    未来数据泄露检测(仅 custom 因子,5个随机时间点)
Step 4C    默认参数云端回测 ← 用户看到的结果来自这里
step 5-11  agent 无需关心

Agent 需要介入的场景:

  • C# 编译失败(failed_step="4c")→ 修复 strategy.cs → retest
  • 未来数据泄露(failed_step="4l")→ Python 插件 build_signal 存在未来函数,重写因子后提交新 job(不能 retest

Step 4C 回测完成后,Agent 即可获取结果并展示给用户,无需等待 Step 5 及之后的步骤


Agent 工作流程(必须按顺序执行)

阶段 -1:选择工作模式

每次启动时,第一步先询问用户选择工作模式:

请问这次要:
A)打工模式 ── 从服务器领取发布的研究任务,AI 自主设计技术路径
B)自由定义 ── 由你直接描述想研究的因子

(输入 A 或 B,或直接描述因子想法)

仓位策略模式不再询问用户——每个因子自动提交两个 job(sigmoid_continuous + quantile_discrete),两种模式的结果一起展示对比。

A. 打工模式

  1. 拉取任务列表并展示:
curl -s ${BASE_URL}/tasks

输出示例:

[
  {"task_id": "task_momentum_001", "title": "短期价格动量", "category": "momentum"},
  {"task_id": "task_volume_001",   "title": "主动成交量失衡", "category": "volume"}
]
  1. 请用户选择一个任务(或 AI 根据本地归档自动选一个尚未研究过的类别)。

  2. 拉取选中任务的完整描述:

TASK_ID="task_momentum_001"
curl -s ${BASE_URL}/tasks/${TASK_ID}
  1. 阅读 descriptionhintsAI 自主决定技术路径(不需要再问用户实现细节),直接进入阶段 0b 提取任务信息。进入阶段 0b 时将 fwd_period 采用任务 JSON 中的值(默认 7)。

注意:任务永远是 open 状态,多个 AI 可以同时研究同一任务,结果越多样越好,不存在"已被领取"的情况

B. 自由定义

用户直接描述因子逻辑,进入阶段 0b(现有流程,无变化)。


阶段 0:确认任务

0a. 查重——检查已研究过的因子

写代码前先扫描本地归档,避免重复研究同类因子:

for f in ./quant_agent/jobs/*/plugin.py; do
  [ -f "$f" ] && grep -H "^FACTOR_TYPE" "$f"
done

输出示例:

./quant_agent/jobs/job_20260312_153001_f4a2c1/plugin.py:FACTOR_TYPE = "rsi_oversold_bounce"
./quant_agent/jobs/job_20260315_063800_a1b2c3/plugin.py:FACTOR_TYPE = "bollinger_breakout"
  • 若用户要求的因子逻辑与已有 FACTOR_TYPE 本质相同(仅参数不同),告知用户已有该因子并展示历史 job_id,询问是改参数重跑还是确认要新建。
  • 若逻辑有实质区别(如 RSI 超卖 vs RSI 背离),正常继续。
  • 归档目录为空时跳过此步。

0b. 提取任务信息

从用户描述中提取:

信息 说明 示例
因子逻辑 用自然语言描述信号如何产生 "RSI低于30时做多"
核心参数 窗口期、阈值等超参 rsi_period=14, oversold=30
factor_type 因子类型标识(snake_case,全局唯一) rsi_oversold_bounce
factor_name 因子名称(含主要参数值) rsi_14_ob30

若用户描述不清晰,主动补问这几项,确认后再写代码。


阶段 1:编写因子插件 plugin.py

插件文件包含两部分,必须同时实现,逻辑必须完全一致

  1. FACTOR_SECTIONS:C# 代码片段,服务器用它生成 strategy.cs
  2. build_signal():Python 函数,服务器用它做超参网格搜索

插件文件完整模板

import pandas as pd
import numpy as np
from typing import Any, Dict

FACTOR_TYPE = "<factor_type>"   # 与提交时的 factor_type 参数保持一致

FACTOR_DEFAULT_PARAMS = {
    "param1": <default_int>,    # 所有超参及默认值,key 用 snake_case
}

FACTOR_SECTIONS = {
    # ── 注释类(人类可读) ───────────────────────────────────────────────
    "__FACTOR_DESCRIPTION__": "因子的中文描述",
    "__FACTOR_FORMULA__":     "信号公式(注释用)",
    "__FACTOR_TYPE__":        "<factor_type>",

    # ── C# 类字段声明(每行末尾必须有 \n) ──────────────────────────────
    "__FACTOR_PARAM_FIELDS__": (
        "        private int _param1;\n"
        # 每个字段一行,注意 8 个空格缩进
    ),

    # ── C# 构造函数初始化(每行末尾必须有 \n) ───────────────────────────
    "__FACTOR_INIT__": (
        '            _param1 = GetIntParameter("param1", <default>);\n'
        # key 用连字符("param-one"),对应 Python 端 key 用下划线("param_one")
    ),

    # ── 初始化日志(每行末尾必须有 \n) ─────────────────────────────────
    "__FACTOR_LOG__": (
        '            Log($"[INIT] param1={_param1}");\n'
    ),

    # ── 滑动窗口大小(合法 C# 整数表达式,不加引号) ─────────────────────
    "__PRICE_WINDOW_EXPR__": "_param1 + 1",

    # ── 额外数据列 Buffer 声明(每行末尾必须有 \n,仅用 close 时填 "") ──
    # FactorCsvBar 字段类型(决定 Enqueue 时是否需要强转):
    #   decimal : Open / High / Low / Close / Volume
    #             → Enqueue 时必须写 (double)bar.Volume 等,否则 CS1503 编译报错
    #   double  : TakerBuyVolume / TakerSellVolume / TakerBuyQuoteVolume /
    #             TakerSellQuoteVolume / TakerBuyTrades / TakerSellTrades / QuoteVolume
    #             → 直接 Enqueue,无需转型
    "__EXTRA_BUF_FIELDS__": "",   # 示例:'        private readonly Queue<double> _volBuf = new Queue<double>();\n'

    # ── 额外列每 bar 入队(每行末尾必须有 \n,不用时填 "") ─────────────
    # decimal 字段示例:'            _volBuf.Enqueue((double)bar.Volume);\n'
    # double  字段示例:'            _takerBuyBuf.Enqueue(bar.TakerBuyVolume);\n'
    "__EXTRA_BUF_ENQUEUE__": "",

    # ── 额外列超窗口出队(每行末尾必须有 \n,不用时填 "") ──────────────
    "__EXTRA_BUF_DEQUEUE__": "",  # 示例:'            if (_volBuf.Count > requiredBars) _volBuf.Dequeue();\n'

    # ── 额外列转数组供计算体使用(每行末尾必须有 \n,不用时填 "") ────────
    "__EXTRA_BUF_TOARRAY__": "",  # 示例:'            var volumes = _volBuf.ToArray();\n'

    # ── C# 信号计算主体 ──────────────────────────────────────────────────
    # 始终可用:prices[](close,从旧到新)
    # 若声明了 __EXTRA_BUF_TOARRAY__,对应数组也在此可用
    # 必须给 rawSignal 赋值(正=看多,负=看空)并 return true
    # 数据不足时 return false(不要 throw)
    "__FACTOR_COMPUTE_BODY__": """
            // C# 计算逻辑
            var n = prices.Length;
            if (n < _param1) return false;
            // ... 计算 ...
            rawSignal = <signal_value>;
            return true;
""",
}


def build_signal(
    close: pd.DataFrame,
    params: Dict[str, Any],
    # 声明因子用到的列(框架自动注入,与 close 地位相同):
    # open, high, low, volume,
    # taker_buy_volume, taker_sell_volume,
    # taker_buy_quote_volume, taker_sell_quote_volume,
    # taker_buy_trades, taker_sell_trades,
    # quote_volume  ← 计算列,由 taker_buy_quote_volume + taker_sell_quote_volume 合成,原始 CSV/zip 无此列
    **_kwargs,
) -> pd.DataFrame:
    """
    close  : pd.DataFrame,index=UTC DatetimeIndex,columns=币种代码
    params : dict,key 与 FACTOR_DEFAULT_PARAMS 一致
    返回   : 与 close 同形状的 DataFrame,正=看多,负=看空,NaN=无信号
    逻辑必须与 FACTOR_SECTIONS.__FACTOR_COMPUTE_BODY__ 完全一致
    """
    param1 = int(params.get("param1", <default>))
    # ... Python 实现 ...
    return signal.reindex_like(close)

Python build_signal 约束(违反会导致 Step 4L 未来数据检测失败)

约束 说明
禁止 shift(-n) 负方向移位读取未来价格,最常见的泄露来源
禁止 pct_change(periods=-n) 负周期同上
禁止 fillna(method='bfill') backward fill 用未来值填补缺失
禁止全局统计归一化 (x - x.mean()) / x.std() 对整列计算,含未来数据
禁止 .rolling(n).mean().shift(-k) rolling 后再负移位
shift 参数必须为正整数 shift(1) 向后看历史,是安全的

C# 代码约束(违反会导致 Step 4C 编译失败)

约束 说明
__PRICE_WINDOW_EXPR__ 必须是纯 C# 整数表达式,不加引号,如 _window + 1
rawSignal 必须在 __FACTOR_COMPUTE_BODY__ 中被赋值
数据不足 return false,不要 throwreturn true 而不赋值
类型 所有计算用 double,不用 decimalfloat
禁止调用 Securities[].GetLastData()PortfolioOrderSetHoldings
参数 key GetIntParameter("param-name", default) 用连字符
每行末尾 __FACTOR_PARAM_FIELDS__ / __FACTOR_INIT__ / __FACTOR_LOG__ / __EXTRA_BUF_FIELDS__ / __EXTRA_BUF_ENQUEUE__ / __EXTRA_BUF_DEQUEUE__ / __EXTRA_BUF_TOARRAY__ 每行末尾加 \n
不用额外列时 __EXTRA_BUF_FIELDS__ / __EXTRA_BUF_ENQUEUE__ / __EXTRA_BUF_DEQUEUE__ / __EXTRA_BUF_TOARRAY__ 填空字符串 ""
额外列数组长度 额外列 buf 使用与 close 相同的 requiredBars 窗口大小
decimal → double 强转 Open/High/Low/Close/Volumedecimal,Enqueue 时必须写 (double)bar.Volume 否则报 CS1503TakerBuy*/TakerSell*/QuoteVolume 已是 double,无需转型

参考实现(完整可运行的例子)

import pandas as pd
import numpy as np
from typing import Any, Dict

FACTOR_TYPE = "rsi_oversold_bounce"

FACTOR_DEFAULT_PARAMS = {
    "rsi_period": 14,
    "oversold":   30,
    "overbought": 70,
}

FACTOR_SECTIONS = {
    "__FACTOR_DESCRIPTION__": "RSI 超卖反弹:RSI < oversold 做多,RSI > overbought 做空",
    "__FACTOR_FORMULA__":     "RSI < oversold → +(oversold-RSI)/oversold; RSI > overbought → -(RSI-overbought)/(100-overbought)",
    "__FACTOR_TYPE__":        "rsi_oversold_bounce",
    "__FACTOR_PARAM_FIELDS__": (
        "        private int _rsiPeriod;\n"
        "        private double _oversold;\n"
        "        private double _overbought;\n"
        "        private double _prevGainEma;\n"
        "        private double _prevLossEma;\n"
        "        private bool _rsiInitialized;\n"
    ),
    "__FACTOR_INIT__": (
        '            _rsiPeriod = GetIntParameter("rsi-period", 14);\n'
        '            _oversold = GetDoubleParameter("oversold", 30.0);\n'
        '            _overbought = GetDoubleParameter("overbought", 70.0);\n'
        '            _prevGainEma = 0.0;\n'
        '            _prevLossEma = 0.0;\n'
        '            _rsiInitialized = false;\n'
    ),
    "__FACTOR_LOG__": (
        '            Log($"[INIT] rsi_period={_rsiPeriod} oversold={_oversold} overbought={_overbought}");\n'
    ),
    "__PRICE_WINDOW_EXPR__": "_rsiPeriod + 1",
    "__EXTRA_BUF_FIELDS__":   "",
    "__EXTRA_BUF_ENQUEUE__":  "",
    "__EXTRA_BUF_DEQUEUE__":  "",
    "__EXTRA_BUF_TOARRAY__":  "",
    "__FACTOR_COMPUTE_BODY__": """
            var n = prices.Length;
            if (n < _rsiPeriod + 1) return false;

            if (!_rsiInitialized)
            {
                double sumGain = 0.0, sumLoss = 0.0;
                for (int i = 1; i < n; i++)
                {
                    var change = prices[i] - prices[i - 1];
                    if (change > 0) sumGain += change;
                    else sumLoss += Math.Abs(change);
                }
                _prevGainEma = sumGain / _rsiPeriod;
                _prevLossEma = sumLoss / _rsiPeriod;
                _rsiInitialized = true;
            }
            else
            {
                var change = prices[n - 1] - prices[n - 2];
                var gain = change > 0 ? change : 0.0;
                var loss = change < 0 ? Math.Abs(change) : 0.0;
                _prevGainEma = (_prevGainEma * (_rsiPeriod - 1) + gain) / _rsiPeriod;
                _prevLossEma = (_prevLossEma * (_rsiPeriod - 1) + loss) / _rsiPeriod;
            }

            double rsi;
            if (_prevLossEma < 1e-12)
                rsi = 100.0;
            else
            {
                var rs = _prevGainEma / _prevLossEma;
                rsi = 100.0 - 100.0 / (1.0 + rs);
            }

            if (rsi < _oversold)
                rawSignal = (_oversold - rsi) / _oversold;
            else if (rsi > _overbought)
                rawSignal = -(rsi - _overbought) / (100.0 - _overbought);
            else
                rawSignal = 0.0;

            return true;
""",
}


def _compute_rsi_wilder(close: pd.DataFrame, period: int) -> pd.DataFrame:
    delta = close.diff()
    gain  = delta.clip(lower=0.0)
    loss  = (-delta).clip(lower=0.0)
    avg_gain = gain.ewm(com=period - 1, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(com=period - 1, min_periods=period, adjust=False).mean()
    rs  = avg_gain / avg_loss.replace(0, np.nan)
    rsi = 100.0 - 100.0 / (1.0 + rs)
    rsi.iloc[:period] = np.nan
    return rsi


def build_signal(close: pd.DataFrame, params: Dict[str, Any], **_) -> pd.DataFrame:
    rsi_period = int(params.get("rsi_period", 14))
    oversold   = float(params.get("oversold",   30.0))
    overbought = float(params.get("overbought", 70.0))

    rsi    = _compute_rsi_wilder(close, rsi_period)
    signal = pd.DataFrame(0.0, index=close.index, columns=close.columns)
    signal[rsi < oversold]   = (oversold   - rsi[rsi < oversold])   / oversold
    signal[rsi > overbought] = -(rsi[rsi > overbought] - overbought) / (100.0 - overbought)
    signal[rsi.isna()]       = np.nan
    return signal.reindex_like(close)

插件写好后保存到一个临时路径供提交用,提交后再按 job_id 归档。

import pandas as pd
import numpy as np
from typing import Any, Dict

FACTOR_TYPE = "taker_buy_ratio_momentum"

FACTOR_DEFAULT_PARAMS = {
    "window": 20,
}

FACTOR_SECTIONS = {
    "__FACTOR_DESCRIPTION__": "主力资金流入:taker 主动买量占比的滚动均值偏离中性线 0.5",
    "__FACTOR_FORMULA__":     "buy_ratio = taker_buy_vol / (buy+sell); signal = rolling_mean(buy_ratio, w) - 0.5",
    "__FACTOR_TYPE__":        "taker_buy_ratio_momentum",
    "__FACTOR_PARAM_FIELDS__": (
        "        private int _window;\n"
    ),
    "__FACTOR_INIT__": (
        '            _window = GetIntParameter("window", 20);\n'
    ),
    "__FACTOR_LOG__": (
        '            Log($"[INIT] window={_window}");\n'
    ),
    "__PRICE_WINDOW_EXPR__": "_window",
    # ── 额外列:taker_buy_volume / taker_sell_volume ──────────────────
    "__EXTRA_BUF_FIELDS__": (
        "        private readonly Queue<double> _takerBuyBuf  = new Queue<double>();\n"
        "        private readonly Queue<double> _takerSellBuf = new Queue<double>();\n"
    ),
    "__EXTRA_BUF_ENQUEUE__": (
        "            _takerBuyBuf.Enqueue(bar.TakerBuyVolume);\n"
        "            _takerSellBuf.Enqueue(bar.TakerSellVolume);\n"
    ),
    "__EXTRA_BUF_DEQUEUE__": (
        "            if (_takerBuyBuf.Count  > requiredBars) _takerBuyBuf.Dequeue();\n"
        "            if (_takerSellBuf.Count > requiredBars) _takerSellBuf.Dequeue();\n"
    ),
    "__EXTRA_BUF_TOARRAY__": (
        "            var takerBuys  = _takerBuyBuf.ToArray();\n"
        "            var takerSells = _takerSellBuf.ToArray();\n"
    ),
    "__FACTOR_COMPUTE_BODY__": """
            var n = prices.Length;
            if (n < _window) return false;

            double sumRatio = 0.0;
            for (int i = 0; i < n; i++)
            {
                var total = takerBuys[i] + takerSells[i];
                var ratio = total > 1e-12 ? takerBuys[i] / total : 0.5;
                sumRatio += ratio;
            }
            rawSignal = sumRatio / n - 0.5;
            return true;
""",
}


def build_signal(
    close:            pd.DataFrame,
    params:           Dict[str, Any],
    taker_buy_volume: pd.DataFrame,
    taker_sell_volume: pd.DataFrame,
    **_kwargs,
) -> pd.DataFrame:
    window = int(params.get("window", 20))
    total = taker_buy_volume + taker_sell_volume
    buy_ratio = taker_buy_volume / total.replace(0, float("nan"))
    signal = buy_ratio.rolling(window).mean() - 0.5
    return signal.reindex_like(close)

阶段 2:提交任务(双模式)

每个因子同时提交两个 job——sigmoid_continuous 和 quantile_discrete:

# 用进程唯一的临时路径,避免多 Agent 并发覆盖
PLUGIN_TMP="/tmp/plugin_${FACTOR_TYPE}_$$.py"

cat > ${PLUGIN_TMP} << 'PLUGIN_EOF'
<plugin 内容>
PLUGIN_EOF

# Job 1: sigmoid_continuous
curl -s -X POST ${BASE_URL}/jobs/submit \
  -F "factor_kind=custom" \
  -F "factor_type=<factor_type>" \
  -F "factor_name=<factor_name>" \
  -F "params=<JSON字符串,如 {\"rsi_period\":14}>" \
  -F "fwd_period=16" \
  -F "plugin=@${PLUGIN_TMP}"

# Job 2: quantile_discrete
curl -s -X POST ${BASE_URL}/jobs/submit \
  -F "factor_kind=custom" \
  -F "factor_type=<factor_type>" \
  -F "factor_name=<factor_name>" \
  -F "params=<JSON字符串>" \
  -F "fwd_period=16" \
  -F "position_mode=quantile_discrete" \
  -F "entry_q=20" \
  -F "plugin=@${PLUGIN_TMP}"

两次提交分别拿到 job_id设为两个 Shell 变量

JOB_ID_SIG="job_20260312_153001_xxxxxx"   # sigmoid_continuous
JOB_ID_QD="job_20260312_153002_yyyyyy"    # quantile_discrete

mkdir -p ./quant_agent/jobs/${JOB_ID_SIG}
mkdir -p ./quant_agent/jobs/${JOB_ID_QD}
cp ${PLUGIN_TMP} ./quant_agent/jobs/${JOB_ID_SIG}/plugin.py
cp ${PLUGIN_TMP} ./quant_agent/jobs/${JOB_ID_QD}/plugin.py
rm -f ${PLUGIN_TMP}

builtin 因子momentum / trend / mean_revert)不需要上传 plugin, 改用 factor_kind=builtin 并省略 -F "plugin=..." 即可(无需归档 plugin.py)。


阶段 3:轮询等待(双 job 并行)

15 秒同时查询两个 job 的状态,最多等待 30 分钟

curl -s ${BASE_URL}/jobs/${JOB_ID_SIG}/status
curl -s ${BASE_URL}/jobs/${JOB_ID_QD}/status

两个 job 独立处理:一个完成不影响另一个的轮询,一个失败也不影响另一个。

Agent 行为速查(对每个 job 独立判断)

status Agent 行为
queued / runningcurrent_step < "5" 继续等待。每 2~3 次轮询告知用户当前进度
runningcurrent_step >= "5")或 done 该 job 的 Step 4C 已完成,标记为可下载
failedfailed_step="4l" Python 插件存在未来数据泄露,重写 build_signal 后提交新 job(禁止 retest)。两个 job 共用同一 plugin,需同时重新提交
failedfailed_step="4c" 进入阶段 3b修复该 job 的 C#
failed(其他 step) 告知用户该 job 服务器内部错误,无法修复
retesting 继续等待
retest_failed 查看 retest 日志,再次修复 strategy.cs

关键规则两个 job 都 current_step >= 5 后才进入阶段 4。若其中一个先完成,继续等另一个;若一个彻底失败(非 C# 编译问题),仍用另一个已完成的结果进入阶段 4,向用户说明哪个模式失败了。

strategy_cs_ready 标志

轮询时若某个 job 返回 "strategy_cs_ready": true,立即下载并归档该 job 的 strategy.cs(只需一次):

# 对 JOB_ID_SIG 和 JOB_ID_QD 分别执行(哪个 ready 就下载哪个)
mkdir -p ./quant_agent/jobs/${JOB_ID_SIG}
curl -s ${BASE_URL}/jobs/${JOB_ID_SIG}/files/strategy.cs \
  -o ./quant_agent/jobs/${JOB_ID_SIG}/strategy.cs

mkdir -p ./quant_agent/jobs/${JOB_ID_QD}
curl -s ${BASE_URL}/jobs/${JOB_ID_QD}/files/strategy.cs \
  -o ./quant_agent/jobs/${JOB_ID_QD}/strategy.cs

阶段 3b:修复 C# 编译错误并 retest

当某个 job status=failedfailed_step"4c" 时,对该 job 执行修复。 两个 job 的 C# 模板不同(sigmoid vs quantile),需分别修复。 以下用 ${JOB_ID} 代指出错的那个 job_id。

1. 查看错误日志

curl -s "${BASE_URL}/jobs/${JOB_ID}/logs?tail=80"

2. 下载并修复 strategy.cs

curl -s ${BASE_URL}/jobs/${JOB_ID}/files/strategy.cs \
  -o ./quant_agent/jobs/${JOB_ID}/strategy.cs

根据日志中的错误信息修改。常见错误速查表:

错误信息 原因 修复方式
CS0019: Operator '/' cannot be applied to 'double' and 'decimal' C# 类型不匹配 在除法前加 (double) 强转
CS0103: The name 'xxx' does not exist 变量名拼写错误或作用域不对 检查 __FACTOR_PARAM_FIELDS__ 中的声明
CS0128: A local variable named 'xxx' is already defined 变量名与模板框架冲突 __FACTOR_COMPUTE_BODY__ 中重命名变量(不要动框架代码
CS1002: ; expected C# 语法错误 检查 __FACTOR_COMPUTE_BODY__ 的每行结尾
rawSignal 始终为 0 return true 前忘记给 rawSignal 赋值 确保所有代码路径都给 rawSignal 赋值
回测运行时 NullReference 访问了未初始化的字段 检查 __FACTOR_INIT__ 是否遗漏了某个字段初始化

修复原则:只修改 #region FactorComputeBody 区域内的代码,不要动框架代码。

3. 提交 retest

curl -s -X POST ${BASE_URL}/jobs/${JOB_ID}/retest \
  -F "strategy_cs=@./quant_agent/jobs/${JOB_ID}/strategy.cs"

返回 { "status": "retesting" } 后回到阶段 3继续轮询。retest 提交后,服务器自动从失败点恢复并跑完所有后续步骤。

若连续 3 次 retest 仍失败,考虑重写 plugin.py 后重新 POST /jobs/submit 开新任务。


阶段 4:获取结果、对比展示、开始下一个因子

⚠️ 关键规则:两个 job 的 current_step >= 5 时 Step 4C 已完成,直接下载文件禁止调用 /result 接口——该接口需要整个 pipeline(Step 16D)跑完才返回数据, 而用户只需要看 Step 4C 的默认参数回测结果,不需要等后续步骤。

4a. 下载产物文件(对两个 job 分别执行)

# 对 JOB_ID_SIG 和 JOB_ID_QD 分别下载(以下用 JOB_ID 代指)
for JOB_ID in ${JOB_ID_SIG} ${JOB_ID_QD}; do
  JOB_DIR=./quant_agent/jobs/${JOB_ID}
  mkdir -p ${JOB_DIR}/step4c

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_factor_card.json \
    -o ${JOB_DIR}/factor_card_default.json

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_equity_curves.png \
    -o ${JOB_DIR}/step4c/equity_curves.png

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_trade_log.csv \
    -o ${JOB_DIR}/step4c/trade_log.csv

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_group_return_plot.png \
    -o ${JOB_DIR}/step4c/group_return_plot.png

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_cs_profile_4panel.png \
    -o ${JOB_DIR}/step4c/cs_profile_4panel.png

  curl -s ${BASE_URL}/jobs/${JOB_ID}/files/default_cs_nav_curves.png \
    -o ${JOB_DIR}/step4c/cs_nav_curves.png
done

若文件尚未生成(旧 job 或 Step 12 尚未完成),curl 会收到 404,忽略即可。

4b. 读取两份 factor_card_default.json 并对比展示

分别读取 SIG 和 QD 的 factor_card_default.json,提取关键指标做对比表格

Sigmoid Continuous(SIG job)关注字段:

JSON 字段 用途
status "pass""fail"
median_sharpe 默认参数中位 Sharpe
icir IC 信息比率
median_annual_return 中位年化收益
median_max_drawdown 中位最大回撤
win_rate 胜率
rank_icir RankICIR(截面预测力)
cs_branch.profile.monotonicity_score 分组单调性打分(若有)

Quantile Discrete(QD job)关注字段:

JSON 字段 含义
status "pass""fail"
median_sharpe C# 回测 Sharpe(信号质量参考)
ts_branch.discrete_turnover 离散状态切换频率(每 bar)
ts_branch.median_hold_bars 中位持有时长(bar 数)
ts_branch.metrics_pool.sharpe_pool 组合级 Sharpe(全币池聚合)
ts_branch.metrics_pool.max_dd_pool 组合级最大回撤
rank_icir RankICIR(截面预测力)
direction_stability 滚动 IC 同号占比(0~1)

重要:QD job 的主要结果来自 Python 侧 Step 8/10 的离散仓位模拟; C# Lean 云端回测(Step 4C)的 median_sharpe 口径是 Sigmoid,仅作信号质量参考

同时展示两个 job 的图表

  • equity_curves.png:TS 时序策略权益曲线(SIG + QD 各一张)
  • group_return_plot.png:CS 截面分组累计收益(两个 job 共用同一份 CS 数据,展示其中一张即可)
  • cs_profile_4panel.png:CS 4 合 1 截面评价图(两个 job 共用同一份 CS 数据,展示其中一张即可)
  • cs_nav_curves.png:CS 净值曲线图——纯多头/纯空头/多空(两个 job 共用,展示其中一张即可)

4c. 对比总结并进入下一轮

对比表格 + 一段话总结两种模式的核心表现:

| 指标             | Sigmoid Continuous | Quantile Discrete |
|------------------|--------------------|-------------------|
| Status           | pass / fail        | pass / fail       |
| Median Sharpe    | x.xx               | x.xx (信号参考)    |
| QD Sharpe Pool   | -                  | x.xx              |
| Rank ICIR        | x.xx               | x.xx              |
| Win Rate         | xx%                | -                 |
| Monotonicity     | x.xx               | x.xx              |
| Hold Bars (QD)   | -                  | xx                |
| Turnover (QD)    | -                  | x.xxxx            |
| Dir Stability    | x.xx               | x.xx              |

总结要点:

  • 哪种模式表现更好、各自优劣
  • 如果某个模式 fail,分析原因并建议改进方向
  • 重点说明 rank_icirdirection_stability 是否支持进入因子库

展示完结果后,直接与用户讨论下一个因子——不需要等服务器做其他事情,这个因子的全部工作已经结束。


完整流程示意图

用户:「研究一个布林带宽度突破因子」
[阶段0] 确认 factor_type / factor_name / params
[阶段1] 写 plugin.py(C# 片段 + Python build_signal)
[阶段2] POST /jobs/submit × 2(sigmoid + quantile)→ 拿到 JOB_ID_SIG + JOB_ID_QD
[阶段3] 并行轮询两个 job,等待 Step 4C 完成(current_step >= 5 或 done)
        ├─ strategy_cs_ready=true → 下载对应 job 的 strategy.cs
        ├─ failed (4c) → [阶段3b] 修该 job 的 C# → retest → 回到轮询
        └─ 两个 job 都 current_step >= 5 或 done(或一个彻底失败)
[阶段4] 下载两个 job 的 default_ 文件 → 对比展示因子卡片 → 讨论下一个因子

其他接口

# 查看 retest 日志
curl -s "${BASE_URL}/jobs/${JOB_ID}/retest_logs?tail=100"

# 健康检查
curl -s ${BASE_URL}/health

向用户汇报进度的节奏

  • 提交任务后立即告知两个 job_id(标注 SIG / QD)
  • 每 2~3 次轮询告知用户两个 job 的当前进度(不必每次都说)
  • 看到 current_step="4c" 时说「正在云端回测,约需 3~5 分钟」
  • 遇到 C# 编译失败(仅 Step 4C)时告知用户「正在修复代码后重试」,不要抛出错误
  • 两个 job 都 current_step >= 5 后,立即停止轮询,获取结果
  • 结果出来后用对比表格展示两种模式,重点突出 pass/fail、median_sharpe、icir
  • 只展示默认参数版卡片,展示完直接进入下一个因子
Installs
26
First Seen
Mar 15, 2026