等波动率仓位 Risk Parity 策略

本策略基于模拟的沪深 300 成分股数据,按 20 日动量因子排序,选取排名靠前的最多 10 只股票,并按照 20 日波动率的倒数确定各标的的目标权重。策略使用 DolphinDB Backtest 引擎完成月度调仓和下一交易日开盘执行,并对回测结果进行绩效分析。整体思路是在通过动量因子筛选具备相对强势标的的基础上,引入波动率倒数加权机制,使组合在获取趋势收益的同时降低高波动标的的权重,从而控制组合风险。通过将选股、权重计算、目标持仓生成、订单执行和绩效统计串联在同一回测流程中,可以较清晰地验证策略逻辑是否符合预期,并为后续扩展到更长历史样本、真实指数成分和更复杂的交易约束提供基础。

1. 策略介绍

本策略同时考虑趋势与风险:先选择近期动量较高的股票,再降低高波动股票的组合权重。以下分别说明策略使用的因子、交易标的和交易规则。

1.1 核心因子

20 日动量(mom20),用于横截面排序;数值越高,说明该股票在过去 20 日的相对涨幅越高,通常可用于刻画短期趋势延续性和市场相对强弱。该因子反映价格在近期窗口内的累计变化,适合在同一股票池内进行横向比较,但也容易受到短期异常波动、停牌复牌或极端行情的影响,因此需要结合有效性过滤后使用。

20 日波动率(vol20),用于计算风险调整后的资金权重;数值越高,代表该股票近期价格波动越剧烈,通常意味着其价格波动风险相对更高。策略通过对 vol20 取倒数,使高波动标的获得较低的配置比例,低波动标的获得较高的配置比例,从而在动量选股的基础上引入波动率调整机制。

策略从 mom20 有效的股票中按降序选择最多 10 只,再按下式计算原始权重:

rawWeight i = 1 vol20 i rawWeight_i=\frac{1}{vol20_i}

最终权重为:

weight i = rawWeight i ∑ j = 1 N rawWeight j weight_i=\frac{rawWeight_i}{\sum_{j=1}^{N}rawWeight_j}

因此,在入选股票数量和动量排序相同的情况下,波动率较低的股票获得较高的目标权重。vol20 缺失、为零或无法形成有效倒数时,不计入权重计算;若所有候选股票的有效波动率权重均为空,策略不写入新的目标数量,保留上一期组合。

例如,两只入选股票的波动率分别为 0.02 和 0.04,则原始权重为 50 和 25,归一化后的目标权重分别约为 66.67% 和 33.33%。这里的权重仅表示该次调仓时的目标资金分配比例,不代表标的的未来收益概率。

1.2 交易标的

策略以带交易所后缀的模拟 A 股日线数据为交易标的,只建立多头持仓,使用自有资金进行交易,不进行卖空、融资或借券。行情频率为日频,mom20 和 vol20 已作为行情表中的字段提供,并随每日行情参与回测。

买卖数量遵守相应品种的最小交易单位:普通股票与 ETF/LOF 的买卖单量均按 100 股(份)的整数倍取整;科创板、创业板、北交所、可转债和期货按脚本中的对应规则处理。策略使用下一交易日开盘价执行交易,因此在信号日使用收盘行情计算因子并生成目标持仓,信号日收盘后的信息不会用于信号日开盘交易。

接入真实数据时,股票池、指数成分、ST 状态、上市日期、停牌状态、涨跌停价、复权和分红信息均应具备历史时点一致性。不应使用当前指数成分股替代历史成分股。

1.3 交易规则

策略按以下顺序完成一次选股和调仓:

  1. 在调仓信号日,根据截至该日可获得的最新指数成分股快照,排除非成分股、ST、上市不足 90 个自然日以及成交量不大于 0 的股票。

  2. 基于同步回放的行情宽表提取 mom20 与 vol20 ;剔除 mom20 无效的标的后进行排序;对于 vol20 缺失或非正的标的,其目标权重设为 0。

  3. 按 mom20 降序排序,最多选择 10 只股票;对入选股票按 1 / vol20 计算并归一化波动率倒数权重。

  4. 用信号日收盘时的账户权益、归一化权重和收盘价计算目标数量。目标数量按相应交易单位向下取整,并预留买入手续费。

  5. 下一交易日按开盘价执行调仓:先卖出未入选标的,以及实际持仓数量超过目标数量的标的,再买入当前持仓数量低于目标数量的股票。

  6. 买入数量同时受目标差额和可用现金限制;卖出数量同时受实际持仓和可卖数量限制。执行完成后清空待执行目标,不会在后续交易日自动追单。

本例在起始日和每月最后一个交易日产生调仓信号。若除息日与调仓日重合,脚本会把有效调仓日顺延至下一个交易日,并补充一个不产生交易信号的中性 bar,以维持复权数据和持仓连续性。

2. 策略回测

下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本次结果已在 DolphinDB Server 3.00.6.1 环境执行。创建引擎前,服务器需要具备与 Server 版本匹配的 Backtest 和模拟撮合相关插件。

2.1 准备数据

策略使用模拟的日频行情。本例使用未复权价格进行订单撮合,订单执行使用当日的开盘价、涨跌停价和成交量约束限制。

本次运行的行情文件共覆盖 30 个模拟标的、6 个交易日,包含 180 条行情记录,日期范围为 2026-08-03 至 2026-08-10。行情表结构如下:

表 1. 表 2-1 行情表结构
列名 类型 说明
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 日开盘执行” 的异步逻辑。

整个函数分为两个阶段:

  1. 订单执行阶段(先卖后买,资金闭环) 当系统检测到上一交易日生成的目标持仓计划时,在当日开盘执行:

    • 先卖出释出现金:对比目标持仓,优先以开盘价卖出多余仓位,并将预计卖出所得资金计入可用资金。

    • 后买入防止透支:汇总可用现金与预估回款,按照相应交易单位取整后逐笔执行买单,随后清空待执行目标持仓计划。

  2. 截面选股阶段(月末调仓决策) 当遇到调仓日时,策略使用当日行情生成新一期调仓计划:

    • 清洗股票池:剔除停牌、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。

表 2. 表 3-1:回测绩效指标汇总
指标名 数据 说明
初始权益 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,策略完成了从因子数据读取、股票池过滤、目标数量计算,到下一交易日开盘执行和回测结果查询的完整流程。

本次模拟结果用于验证策略实现与回测流程,不构成对真实市场有效性或未来收益的判断。在真实投资研究中,应使用严格按历史可得时点构造的因子、股票池和公司行为数据,并在覆盖多个完整市场周期的更长样本上进行稳健性检验。