等波动率仓位 Risk Parity 策略
本策略基于模拟的沪深 300 成分股数据,按 20 日动量因子排序,选取排名靠前的最多 10 只股票,并按照 20 日波动率的倒数确定各标的的目标权重。策略使用 DolphinDB Backtest 引擎完成月度调仓和下一交易日开盘执行,并对回测结果进行绩效分析。整体思路是在通过动量因子筛选具备相对强势标的的基础上,引入波动率倒数加权机制,使组合在获取趋势收益的同时降低高波动标的的权重,从而控制组合风险。通过将选股、权重计算、目标持仓生成、订单执行和绩效统计串联在同一回测流程中,可以较清晰地验证策略逻辑是否符合预期,并为后续扩展到更长历史样本、真实指数成分和更复杂的交易约束提供基础。
1. 策略介绍
本策略同时考虑趋势与风险:先选择近期动量较高的股票,再降低高波动股票的组合权重。以下分别说明策略使用的因子、交易标的和交易规则。
1.1 核心因子
20 日动量(mom20),用于横截面排序;数值越高,说明该股票在过去 20 日的相对涨幅越高,通常可用于刻画短期趋势延续性和市场相对强弱。该因子反映价格在近期窗口内的累计变化,适合在同一股票池内进行横向比较,但也容易受到短期异常波动、停牌复牌或极端行情的影响,因此需要结合有效性过滤后使用。
20 日波动率(vol20),用于计算风险调整后的资金权重;数值越高,代表该股票近期价格波动越剧烈,通常意味着其价格波动风险相对更高。策略通过对 vol20 取倒数,使高波动标的获得较低的配置比例,低波动标的获得较高的配置比例,从而在动量选股的基础上引入波动率调整机制。
策略从 mom20 有效的股票中按降序选择最多 10 只,再按下式计算原始权重:
最终权重为:
因此,在入选股票数量和动量排序相同的情况下,波动率较低的股票获得较高的目标权重。vol20 缺失、为零或无法形成有效倒数时,不计入权重计算;若所有候选股票的有效波动率权重均为空,策略不写入新的目标数量,保留上一期组合。
例如,两只入选股票的波动率分别为 0.02 和 0.04,则原始权重为 50 和 25,归一化后的目标权重分别约为 66.67% 和 33.33%。这里的权重仅表示该次调仓时的目标资金分配比例,不代表标的的未来收益概率。
1.2 交易标的
策略以带交易所后缀的模拟 A 股日线数据为交易标的,只建立多头持仓,使用自有资金进行交易,不进行卖空、融资或借券。行情频率为日频,mom20 和 vol20 已作为行情表中的字段提供,并随每日行情参与回测。
买卖数量遵守相应品种的最小交易单位:普通股票与 ETF/LOF 的买卖单量均按 100 股(份)的整数倍取整;科创板、创业板、北交所、可转债和期货按脚本中的对应规则处理。策略使用下一交易日开盘价执行交易,因此在信号日使用收盘行情计算因子并生成目标持仓,信号日收盘后的信息不会用于信号日开盘交易。
接入真实数据时,股票池、指数成分、ST 状态、上市日期、停牌状态、涨跌停价、复权和分红信息均应具备历史时点一致性。不应使用当前指数成分股替代历史成分股。
1.3 交易规则
策略按以下顺序完成一次选股和调仓:
-
在调仓信号日,根据截至该日可获得的最新指数成分股快照,排除非成分股、ST、上市不足 90 个自然日以及成交量不大于 0 的股票。
-
基于同步回放的行情宽表提取 mom20 与 vol20 ;剔除 mom20 无效的标的后进行排序;对于 vol20 缺失或非正的标的,其目标权重设为 0。
-
按 mom20 降序排序,最多选择 10 只股票;对入选股票按
1 / vol20计算并归一化波动率倒数权重。 -
用信号日收盘时的账户权益、归一化权重和收盘价计算目标数量。目标数量按相应交易单位向下取整,并预留买入手续费。
-
下一交易日按开盘价执行调仓:先卖出未入选标的,以及实际持仓数量超过目标数量的标的,再买入当前持仓数量低于目标数量的股票。
-
买入数量同时受目标差额和可用现金限制;卖出数量同时受实际持仓和可卖数量限制。执行完成后清空待执行目标,不会在后续交易日自动追单。
本例在起始日和每月最后一个交易日产生调仓信号。若除息日与调仓日重合,脚本会把有效调仓日顺延至下一个交易日,并补充一个不产生交易信号的中性 bar,以维持复权数据和持仓连续性。
2. 策略回测
下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本次结果已在 DolphinDB Server 3.00.6.1 环境执行。创建引擎前,服务器需要具备与 Server 版本匹配的 Backtest 和模拟撮合相关插件。
2.1 准备数据
策略使用模拟的日频行情。本例使用未复权价格进行订单撮合,订单执行使用当日的开盘价、涨跌停价和成交量约束限制。
本次运行的行情文件共覆盖 30 个模拟标的、6 个交易日,包含 180 条行情记录,日期范围为 2026-08-03 至 2026-08-10。行情表结构如下:
| 列名 | 类型 | 说明 |
|---|---|---|
| symbol | SYMBOL | 模拟股票编号加交易所后缀 |
| tradeTime | TIMESTAMP | 交易日时间戳 |
| open / low / high / close | DOUBLE | 日线价格 |
| volume | LONG | 成交股数 |
| amount | DOUBLE | 成交额 |
| upLimitPrice / downLimitPrice | DOUBLE | 涨跌停价格 |
| adj_factor | INT | 复权因子 |
| mom20 | DOUBLE | 20 日动量,用于选股排序 |
| vol20 | DOUBLE | 20 日波动率,用于倒数加权 |
策略还在内存中构造股票池、指数成分股快照、ST 历史、上市信息和模拟分红表。该示例中的指数成分、ST 和分红数据为模拟输入,仅用于演示回测流程,不代表真实沪深 300 的历史成分关系或实际公司行为。
2.2 编写策略逻辑
策略由 6 个函数组成。roundLot 负责将买入数量取整至相应交易单位;normalizeSellQty 负责规范卖出数量并处理零股;isBuyableAt 与 isSellableAt 负责根据涨跌停和成交量等条件判断订单是否可执行;initialize 初始化上下文与静态参数;onBar 负责执行上一期目标持仓并生成新一期目标持仓。
买入数量取整函数:
roundLot 根据标的识别所属板块(主板、科创板、ETF等),将原始建议买入量向下取整至交易所允许的申报单位(如主板 100 股整数倍,科创板 200 股起)。
卖单规范与零股处理函数:
normalizeSellQty 控制合规卖出量。对于部分减仓,严格按对应板块或品种的交易规则取整;当触发清仓时(请求卖出量 ≥ 实际持仓),允许一次性卖出全部实际持仓,包括不足一个交易单位的零股。
流动性拦截函数:
isBuyableAt
与
isSellableAt 检查指定价格和当日行情判断订单是否可执行。当日无成交量时视为停牌,不执行买卖;当开盘价触及涨停价时禁止买入,触及跌停价时禁止卖出,以模拟相应的交易限制。
初始化函数:
initialize 在回测引擎启动阶段仅执行一次,接收策略的上下文环境字典 context,主要用于配置交易参数、建立状态缓存,并加载外部静态参考数据。
-
交易成本配置: 设定双边交易佣金、卖方单边印花税。
-
交易状态容器: 初始化记录触发信号的日期和等待下一交易日执行的目标持仓字典。这两个变量构成了跨日订单执行机制的核心。
-
静态数据挂载: 加载预先处理好的股票池、成分股快照、ST历史区间、上市信息及月度调仓日历。
def initialize(mutable context) {
context["commission"] = 0.00015
context["tax"] = 0.0005
context["signalDate"] = NULL
context["pendingTargetQtys"] = dict(STRING, LONG)
context["stockPool"] = objByName("stockPool")
context["tpIdxSnaps"] = objByName("tpIdxSnaps_")
context["tpStHist"] = objByName("tpStHist_")
context["tpListInfo"] = objByName("tpListInfo_")
context["rebalanceSchedule"] = objByName("rebalanceSchedule_")
}
onBar 是整个策略的“心脏”,由回测引擎按交易日流式触发。为了贴近真实市场,它严格遵循 “使用 T 日收盘行情生成调仓计划,T+1 日开盘执行” 的异步逻辑。
整个函数分为两个阶段:
-
订单执行阶段(先卖后买,资金闭环) 当系统检测到上一交易日生成的目标持仓计划时,在当日开盘执行:
-
先卖出释出现金:对比目标持仓,优先以开盘价卖出多余仓位,并将预计卖出所得资金计入可用资金。
-
后买入防止透支:汇总可用现金与预估回款,按照相应交易单位取整后逐笔执行买单,随后清空待执行目标持仓计划。
-
-
截面选股阶段(月末调仓决策) 当遇到调仓日时,策略使用当日行情生成新一期调仓计划:
-
清洗股票池:剔除停牌、ST、非成分股及上市不足 90 个自然日的股票。
-
动量选取:按 20 日动量(mom20)降序排序,最多选取前 10 只强势股。
-
波动率倒数加权:按 20 日波动率(
vol20)的倒数进行加权,即历史波动越小,分配资金权重越大。 -
生成次日计划:按当前账户权益和计算得到的目标权重确定目标持仓数量,存入目标表等待次日执行。
-
完整策略流程如下:
def onBar(mutable context, msg, indicator) {
curDate = date(msg.values()[0]["tradeTime"])
// 补出来的中性 bar 只用来保持复权与持仓连续,当天不做任何交易决策。
if (curDate in objByName("syntheticDivDates_")) { return }
// 执行日:目标表中有键即锁定目标;没有键即目标为 0。所有卖单先提交,回款再供买单使用。
if (context["signalDate"] != NULL && curDate != context["signalDate"]) {
tgtQtys_ = context["pendingTargetQtys"]
expectedCash_ = Backtest::getAvailableCash(context.engine)
sellCashMult_ = 1.0 - context["commission"] - context["tax"]
// 唯一一次仓位扫描:生成局部订单计划;计划不写入 context。
sellPlan_ = dict(STRING, LONG)
buyPlan_ = dict(STRING, LONG)
for (istock in msg.keys()) {
bar = msg[istock]
posInfo_ = Backtest::getPosition(context.engine, istock)
pos_ = 0
sellable_ = 0
if (size(posInfo_) > 0) {
pos_ = posInfo_["longPosition"]
sellable_ = max(0, posInfo_["lastDayLongPosition"] - posInfo_["todaySellVolume"])
}
targetQty_ = iif(istock in tgtQtys_, tgtQtys_[istock], 0)
sellQty_ = normalizeSellQty(istock, pos_, min(sellable_, max(0, pos_ - targetQty_)))
if (sellQty_ > 0 && isSellableAt(bar["open"], bar)) {
sellPlan_[istock] = sellQty_
}
postSellPos_ = pos_ - iif(istock in sellPlan_, sellPlan_[istock], 0)
buyNeed_ = max(0, targetQty_ - postSellPos_)
if ((istock in tgtQtys_) && buyNeed_ > 0 && isBuyableAt(bar["open"], bar)) { buyPlan_[istock] = buyNeed_ }
}
// 第一阶段:全部卖单提交后再累加回款。
for (istock in sellPlan_.keys()) {
bar = msg[istock]
sellQty_ = sellPlan_[istock]
sellLimit_ = bar["open"]
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, sellLimit_, sellQty_, 3), "rebalance")
expectedCash_ = expectedCash_ + sellQty_ * bar["open"] * sellCashMult_
}
// 第二阶段:按可用现金逐笔补足买入缺口。
for (istock in buyPlan_.keys()) {
bar = msg[istock]
affordableCash_ = min(expectedCash_, Backtest::getAvailableCash(context.engine))
affordableQty_ = roundLot(istock, affordableCash_ / (bar["open"] * (1 + context["commission"])))
buyQty_ = roundLot(istock, min(buyPlan_[istock], affordableQty_))
if (buyQty_ > 0) {
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], buyQty_, 1), "buy")
expectedCash_ = expectedCash_ - buyQty_ * bar["open"] * (1 + context["commission"])
}
}
context["signalDate"] = NULL
context["pendingTargetQtys"] = dict(STRING, LONG)
}
// === 选股逻辑(信号日选股,下根 bar 开盘下单)===
if (curDate in context["rebalanceSchedule"]) {
// 取当日最新一期指数成分股快照
snapsT_ = context["tpIdxSnaps"]
latestPerIdx_ = select index_code, max(snap_date) as latest_date from snapsT_ where snap_date <= curDate group by index_code
idxMembers_ = set(exec con_code from ej(snapsT_, latestPerIdx_, `index_code`snap_date, `index_code`latest_date))
// 剔除当日处于 ST 状态的股票
stHist_ = context["tpStHist"]
stCodes_ = set(exec code from stHist_ where start_date <= curDate and (isNull(period_end) or period_end > curDate))
// 剔除上市不足 N 天的新股
listInfo_ = context["tpListInfo"]
cutoffDate_ = curDate - 90
eligibleCodes_ = set(exec code from listInfo_ where list_date <= cutoffDate_)
pool_ = array(STRING, 0)
for (s_ in context["stockPool"]) { if ((s_ in idxMembers_) && !(s_ in stCodes_) && (s_ in eligibleCodes_)) { pool_.append!(s_) } }
poolSet_ = set(pool_)
// --- 横截面选股 ---
symbols = []$STRING
m_mom20_list = []$DOUBLE
m_vol20_list = []$DOUBLE
for (istock in msg.keys()) {
if (!(istock in poolSet_)) { continue }
bar = msg[istock]
if (bar["volume"] <= 0) { continue } // 跳过停牌股
symbols.append!(istock)
m_mom20_list.append!(double(msg[istock]["mom20"]))
m_vol20_list.append!(double(msg[istock]["vol20"]))
}
poolT = table(symbols as symbol, m_mom20_list as `mom20, m_vol20_list as `vol20)
if (poolT.size() == 0) { return }
rankValidT_ = select * from poolT where isValid(mom20)
if (rankValidT_.size() == 0) { return }
limitN_ = iif(10 < rankValidT_.size(), 10, rankValidT_.size())
topT_ = select * from rankValidT_ order by (mom20) desc limit limitN_
selectedStocks_ = exec symbol from topT_
selectedVols_ = exec vol20 from topT_
rawWeights_ = dict(STRING, DOUBLE)
sumWeights_ = 0.0
for (i_ in 0..(size(selectedStocks_)-1)) {
rawWeight_ = 1.0 / selectedVols_[i_]
if (isValid(rawWeight_) && rawWeight_ > 0.0) {
rawWeights_[selectedStocks_[i_]] = rawWeight_
sumWeights_ = sumWeights_ + rawWeight_
}
}
// 波动率全无效时不写目标表,因此保留上一期组合。
if (rawWeights_.size() == 0) { return }
signalEquity_ = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
tgtQtys_ = dict(STRING, LONG)
for (s_ in rawWeights_.keys()) {
weight_ = rawWeights_[s_] / sumWeights_
tgtQtys_[s_] = roundLot(s_, signalEquity_ * weight_ / (msg[s_]["close"] * (1 + context["commission"])))
}
context["pendingTargetQtys"] = tgtQtys_
context["signalDate"] = curDate
}
}
2.3 设置回测参数
这里将回测引擎参数配置在 config,并将策略运行所需的参数、数据源配置和日志表存储在 context 中。完整配置如下:
config = {
startDate: actualStartDate,
endDate: backtestEndDate,
strategyGroup: "stock",
cash: 1000000.0,
commission: 0.00015,
tax: 0.0005,
dataType: 4,
outputOrderInfo: true,
matchingMode: 2,
orderBookMatchingRatio: 0.2,
enableMinimumPerTransactionFee: false
}
本例将 createBacktester 的第四个参数固定为 false,以非 JIT 模式执行。设置为 true 可启用 JIT,但启用前需要检查所有回调是否兼容,包括上下文变量预声明、变量类型和表操作等。具体检查方法参阅DolphinDB 回测引擎使用和性能优化攻略。
2.4 创建回测引擎
模拟撮合引擎负责订单撮合,Backtest 负责事件回调、持仓、资金和绩效管理。脚本先尝试清理同名的已有引擎,再注册初始化回调和日线回调并创建新的引擎实例,以避免复用已有的交易状态。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "riskParitySimplifiedBacktester"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
重复运行完整脚本时,应从数据准备开始重新执行;不要在已有状态的引擎上重复回放相同行情。
2.5 执行回测
脚本将按时间顺序排列的行情表写入回测引擎,由回调函数逐日处理策略信号和订单。行情回放完成后,通过 appendEndMarker 通知引擎回放结束,并保留引擎实例供后续查询回测结果。
Backtest::appendQuotationMsg(engine, data)
Backtest::appendEndMarker(engine);
2.6 获取回测结果
回放完成后,脚本查询收益概述、订单明细、每日持仓和每日账户权益。totalEquity / config["cash"] 即策略净值;本策略没有配置外部资金流,因此权益变化来自持仓市值、已实现损益和交易成本。
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
各结果对象的主要用途如下:
-
returnSummary:查看引擎计算的收益、风险和交易统计。
-
tradeDetails:查看订单状态和成交;仅 tradeQty > 0 的记录表示实际发生了成交。
-
dailyPosition:查看每日持仓、买卖数量和收盘估值。
-
totalPortfolios:查看每日账户权益和策略净值。
脚本未设置真实指数基准,因此收益概述中涉及基准的字段不能解释为相对沪深 300 的超额收益。
3. 回测结果分析
在上述 Server 和插件版本下,本例以非 JIT 模式运行回测,处理 2026-08-03 至 2026-08-10 的 6 个交易日、共 180 条行情记录。2026-08-03 产生选股信号,首批订单在下一交易日 2026-08-04 开盘发出,符合“T 日生成调仓计划、T+1 日开盘执行”的设计。
本轮共选取 10 只股票:000004.SH、000006.SH、000008.SH、000011.SH、000015.SH、000019.SH、000023.SH、000024.SH、000027.SH 和 000030.SH。
| 指标名 | 数据 | 说明 |
|---|---|---|
| 初始权益 | 1,000,000.00 元 | 回测起始账户权益 |
| 期末权益 | 1,302,269.996412 元 | 回测结束时账户总权益 |
| 期末净值 | 1.302269996 | totalEquity / 初始现金 |
| 区间总收益 | 30.2270% | 样本区间内策略累计收益 |
| 累计手续费 | 61.613535 元 | 回测期间累计产生的交易手续费 |
| 阶段峰值日期 | 2026-08-06 | 样本内净值达到阶段峰值的日期 |
| 最大回撤 | 30.0353% | 阶段峰值后出现的最大回撤幅度 |
| 样本长度 | 6 个交易日 | 样本区间较短,年化收益字段会被极端放大,不应作为策略长期收益能力的判断依据。 |
由于本例样本仅覆盖 6 个交易日,样本区间较短,年化收益等按短期收益推算的指标容易被极端放大,不适合作为策略长期收益能力的判断依据。最大回撤等风险指标同样仅反映本样本区间内的统计结果。
本轮回测仅完成首次建仓,样本期间没有覆盖第二个月度调仓日,因此尚不足以验证完整的月度再平衡过程。该结果主要用于验证因子数据读取与筛选、动量排序、波动率倒数加权、目标持仓生成、下一交易日开盘执行以及订单撮合等策略链路是否符合预期,而不能据此评价策略的长期收益能力或实际交易表现。
实际研究中,应使用覆盖多个完整月度调仓周期的历史行情,并进一步评估换手率、持仓集中度、行业暴露、交易成本以及样本外稳定性等指标。
4. 总结
本策略先根据 20 日动量确定候选股票,再根据 20 日波动率倒数分配目标资金权重。相较于等权配置,该方法会降低高波动标的的目标权重。通过 DolphinDB Backtest,策略完成了从因子数据读取、股票池过滤、目标数量计算,到下一交易日开盘执行和回测结果查询的完整流程。
本次模拟结果用于验证策略实现与回测流程,不构成对真实市场有效性或未来收益的判断。在真实投资研究中,应使用严格按历史可得时点构造的因子、股票池和公司行为数据,并在覆盖多个完整市场周期的更长样本上进行稳健性检验。
5. 附录
以下为本策略的完整策略脚本,包含策略函数、回测配置和引擎执行流程,以及配套的模拟行情数据文件:
