Crypto 单因子量化研究服务 Skill。当用户说"写一个因子"、"研究因子"、"量化打工"、 "提交因子"、"因子回测"时加载此 Skill。 Agent 负责编写因子插件代码并通过 HTTP 接口与服务器交互, 服务器负责所有数据处理和计算,Agent 本地无需任何市场数据。
BASE_URL = http://54.151.204.72:8000
启动前用
curl ${BASE_URL}/health确认服务在线,若连接失败立刻告知用户。
run_workflow.py、step*.py 等),所有计算都在服务器上。/home/ec2-user/quant-factor-loop/ 下的任何文件,该目录只属于服务器内部。每次开始新任务时,将以下路径记录到工作变量中:
./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 全程只需轮询等待:
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 及之后的步骤。
每次启动时,第一步先询问用户选择工作模式:
请问这次要:
A)打工模式 ── 从服务器领取发布的研究任务,AI 自主设计技术路径
B)自由定义 ── 由你直接描述想研究的因子
(输入 A 或 B,或直接描述因子想法)
仓位策略模式不再询问用户——每个因子自动提交两个 job(sigmoid_continuous + quantile_discrete),两种模式的结果一起展示对比。
curl -s ${BASE_URL}/tasks
输出示例:
[
{"task_id": "task_momentum_001", "title": "短期价格动量", "category": "momentum"},
{"task_id": "task_volume_001", "title": "主动成交量失衡", "category": "volume"}
]
请用户选择一个任务(或 AI 根据本地归档自动选一个尚未研究过的类别)。
拉取选中任务的完整描述:
TASK_ID="task_momentum_001"
curl -s ${BASE_URL}/tasks/${TASK_ID}
description 和 hints,AI 自主决定技术路径(不需要再问用户实现细节),直接进入阶段 0b 提取任务信息。进入阶段 0b 时将 fwd_period 采用任务 JSON 中的值(默认 7)。注意:任务永远是
open状态,多个 AI 可以同时研究同一任务,结果越多样越好,不存在"已被领取"的情况。
用户直接描述因子逻辑,进入阶段 0b(现有流程,无变化)。
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,询问是改参数重跑还是确认要新建。0b. 提取任务信息
从用户描述中提取:
| 信息 | 说明 | 示例 |
|---|---|---|
| 因子逻辑 | 用自然语言描述信号如何产生 | "RSI低于30时做多" |
| 核心参数 | 窗口期、阈值等超参 | rsi_period=14, oversold=30 |
factor_type | 因子类型标识(snake_case,全局唯一) | rsi_oversold_bounce |
factor_name | 因子名称(含主要参数值) | rsi_14_ob30 |
若用户描述不清晰,主动补问这几项,确认后再写代码。
插件文件包含两部分,必须同时实现,逻辑必须完全一致:
FACTOR_SECTIONS:C# 代码片段,服务器用它生成 strategy.csbuild_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, quote_volume,
# taker_buy_volume, taker_sell_volume,
# taker_buy_quote_volume, taker_sell_quote_volume,
# taker_buy_trades, taker_sell_trades
**_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)
| 约束 | 说明 |
|---|---|
禁止 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) 向后看历史,是安全的 |
| 约束 | 说明 |
|---|---|
__PRICE_WINDOW_EXPR__ | 必须是纯 C# 整数表达式,不加引号,如 _window + 1 |
rawSignal | 必须在 __FACTOR_COMPUTE_BODY__ 中被赋值 |
| 数据不足 | 用 return false,不要 throw 或 return true 而不赋值 |
| 类型 | 所有计算用 double,不用 decimal 或 float |
| 禁止调用 | Securities[].GetLastData()、Portfolio、Order、SetHoldings |
| 参数 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/Volume 是 decimal,Enqueue 时必须写 (double)bar.Volume 否则报 CS1503;TakerBuy*/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)
每个因子同时提交两个 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)。
每 15 秒同时查询两个 job 的状态,最多等待 30 分钟:
curl -s ${BASE_URL}/jobs/${JOB_ID_SIG}/status
curl -s ${BASE_URL}/jobs/${JOB_ID_QD}/status
两个 job 独立处理:一个完成不影响另一个的轮询,一个失败也不影响另一个。
| status | Agent 行为 |
|---|---|
queued / running(current_step < "5") | 继续等待。每 2~3 次轮询告知用户当前进度 |
running(current_step >= "5")或 done | 该 job 的 Step 4C 已完成,标记为可下载 |
failed(failed_step="4l") | Python 插件存在未来数据泄露,重写 build_signal 后提交新 job(禁止 retest)。两个 job 共用同一 plugin,需同时重新提交 |
failed(failed_step="4c") | 进入阶段 3b修复该 job 的 C# |
failed(其他 step) | 告知用户该 job 服务器内部错误,无法修复 |
retesting | 继续等待 |
retest_failed | 查看 retest 日志,再次修复 strategy.cs |
关键规则:两个 job 都
current_step >= 5后才进入阶段 4。若其中一个先完成,继续等另一个;若一个彻底失败(非 C# 编译问题),仍用另一个已完成的结果进入阶段 4,向用户说明哪个模式失败了。
轮询时若某个 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
当某个 job status=failed 且 failed_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开新任务。
⚠️ 关键规则:两个 job 的
current_step >= 5时 Step 4C 已完成,直接下载文件。 禁止调用/result接口——该接口需要整个 pipeline(Step 16D)跑完才返回数据, 而用户只需要看 Step 4C 的默认参数回测结果,不需要等后续步骤。
# 对 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,忽略即可。
分别读取 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 共用,展示其中一张即可)用对比表格 + 一段话总结两种模式的核心表现:
| 指标 | 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 |
总结要点:
rank_icir 和 direction_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
current_step="4c" 时说「正在云端回测,约需 3~5 分钟」current_step >= 5 后,立即停止轮询,获取结果