股票、债券与商品跨资产

股票、债券与大宗商品的收益来源和波动特征互不相同,把资金按固定比例分散到三类资产并定期再平衡,可以在不预测市场的前提下压低组合波动。本策略把组合拆成股票腿、债券 ETF 腿、商品 ETF 腿三条腿,11 只标的全等权,每月首个交易日把市值拉回目标比例。

1. 策略介绍

策略不判断涨跌,唯一的决策是把漂移后的持仓市值恢复到目标比例:某个标的涨多了就减仓、跌多了就补仓。三条腿的意义在于分散——股票腿贡献增长、债券腿提供稳定性、商品腿对冲通胀与风险偏好切换,三者相关性低,组合波动会低于各腿的加权平均。代价是每次再平衡都要付佣金(股票腿还要付印花税),因此调仓频率与费率直接决定策略是否划算。

1.1 核心因子

组合分成三条腿,每条腿的腿内标的等权;由于 11 只标的全等权(各占 1/11),三条腿的比例由腿内标的数量自然得出:

  • 股票腿比例 = 5/11(5 只股票),债券 ETF 腿比例 = 3/11,商品 ETF 腿比例 = 3/11;

  • 公式里的 1 - 2 x 手续费 - 印花税 是成本折让:一次完整调仓包含买入与卖出两笔佣金,股票腿还额外交一笔印花税,先把这部分成本从目标市值里扣掉,买入时就不会透支现金;债券 ETF 与商品 ETF 免印花税,折让只扣两笔佣金。

再平衡频率由权重表决定:先按交易日生成全标的等权权重,再用 context by month(tradeDay) having tradeDay == min(tradeDay) 截取每月首个交易日,样本区间 2012-02 至 2022-04 共 123 个调仓日。

1.2 交易标的

表 1. 表 1-1 跨资产配置标的表
腿 比例 标的 腿内配置
股票腿 5/11 000001.SZ、000002.SZ、000003.SZ、000004.SZ、000005.SZ 等权,各 1/11
债券 ETF 腿 3/11 510001.SH、510002.SH、510003.SH 等权,各 1/11
商品 ETF 腿 3/11 518880.SH(黄金 ETF)、159980.SZ(有色 ETF)、159985.SZ(豆粕 ETF) 等权,各 1/11

11 只标的全部是模拟证券,代码沿用真实市场的命名习惯(股票 000001.SZ、债券 ETF 510xxx.SH、商品 ETF 518880.SH / 159xxx.SZ),但不代表任何真实的股票、基金或指数。三条腿同处一个股票账户(strategyGroup 为 "stock"),只做多、不融资、不融券,行情为日频,订单按委托价成交。

关于商品腿的表达方式:本策略按“股票账户可交易的日线标的”口径实现商品腿,即商品 ETF(与债券腿用债券 ETF 表达同理);若需要真实的商品期货(保证金、双向持仓、合约乘数),需要把账户改为 FICC 多资产账户或全期货账户,属于另一套引擎配置。

1.3 交易规则

  1. onBar 先取当日调仓记录,取不到就直接返回,因此只有每月首个交易日会产生委托。

  2. 有记录时先取当日账户权益,三条腿共用同一个权益基准。

  3. 每条腿按「权益 × 腿比例 × 成本折让 × 腿内权重」算出目标市值,再按当日开盘价折算目标数量并取整:股票与场内基金均按 100 整数倍(科创板 68 开头为 200 股起、逐股递增)。

  4. 先卖出:目标数量小于当前持仓的标的按开盘价卖出差额;卖出前检查停牌(成交量不大于 0)与跌停(收盘价等于跌停价)。

  5. 再买入:目标数量大于当前持仓的标的,买入量取「可用资金上限」与「目标缺口」的较小值;买入前检查停牌与涨停。同一个腿内先下的委托会占用资金,后下单标的的可用资金相应减少。

  6. 三条腿依次执行:股票腿 → 债券 ETF 腿 → 商品 ETF 腿;每条腿只交易属于自己腿内的标的,互不干扰。

2. 策略回测

本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

下载附录中的 stockDailyBar_assetAllocation.csv 文件,作为回测的案例数据。导入数据前,请先将 path 变量改为 CSV 文件的实际存储路径,再执行下方代码。

表 2. 表 2-1 模拟行情数据表结构
列名 类型 含义
symbol STRING 标的代码
tradeTime TIMESTAMP 交易日
open DOUBLE 当日开盘价
low、high DOUBLE 当日最低价、最高价
close DOUBLE 当日收盘价
volume LONG 当日成交量
amount DOUBLE 当日成交额
upLimitPrice、downLimitPrice DOUBLE 涨停价、跌停价
prevClosePrice DOUBLE 前收盘价

脚本读取该文件后把列名与类型对齐到引擎要求:

path = "/.../"
dailyRaw = loadText(path + "stockDailyBar_assetAllocation.csv")
dailyData = select symbol$SYMBOL as symbol, timestamp(tradeTime) as tradeTime,
        double(open) as open, double(low) as low, double(high) as high, double(close) as close,
        long(volume) as volume, double(amount) as amount, double(upLimitPrice) as upLimitPrice,
        double(downLimitPrice) as downLimitPrice, double(prevClosePrice) as prevClosePrice
    from dailyRaw order by tradeTime, symbol

2.2 编写策略逻辑

脚本由交易约束、单腿再平衡、每日回调三部分组成。交易约束函数处理能不能交易与能买多少,rebalanceLeg 完成一条腿的全部调仓动作,onBar 在调仓日依次调用三条腿。

交易约束函数

  • isBuyAble(data):data 是当日该标的的行情字典。成交量不大于 0.001(视为停牌)或收盘价等于涨停价时返回 false,其余返回 true。

  • isSellAble(data):判断停牌的方式同上,另加收盘价等于跌停价时返回 false。

  • getBuyAbleVolume(istock, cash, price, lotSize):按可用资金反算可买数量。istock 为标的代码,cash 为可用资金,price 为委托价,lotSize 为每手数量。科创板( 68 开头)可用数量达到 200 股后逐股递增,不足 200 股返回 0;其余标的按 lotSize 向下取整。

  • getOpenPrice(msg, istock):取出指定标的的当日开盘价,供权重表批量取价使用。

def isBuyAble(data) {
    if (data["volume"] <= 0.001 or data["upLimitPrice"] == data["close"]) { return false }
    return true
}
def isSellAble(data) {
    if (data["volume"] <= 0.001 or data["downLimitPrice"] == data["close"]) { return false }
    return true
}
// 可用资金上限内的可买数量:按每手数量取整;科创板(68 开头)200 股起逐股递增
def getBuyAbleVolume(istock, cash, price, lotSize) {
    if (price <= 0.) { return 0 }
    if (istock.substr(0, 2) == "68" and floor(cash / price) >= 200) { return floor(cash / price) }
    if (istock.substr(0, 2) == "68") { return 0 }
    return floor(floor(cash / price) / lotSize) * lotSize
}
def getOpenPrice(msg, istock) { return msg[istock]["open"] }

单腿再平衡函数:rebalanceLeg(mutable context, msg, weightsToday, legPercent, lotSize, costFactor)

weightsToday 是当日该腿的腿内权重表(含 symbol、weight 两列),legPercent 是该腿的比例(股票腿 5/11、债券与商品 ETF 腿各 3/11),lotSize 是每手数量,costFactor 是成本折让系数。函数在权重表上依次追加四列——targetMv(目标市值)、px(当日开盘价)、targetPos(目标数量)、curPos(当前持仓)——随后先卖后买。

// 目标市值 = 账户权益 × 腿比例 × 成本折让 × 腿内权重;换算目标数量后先卖后买
def rebalanceLeg(mutable context, msg, weightsToday, legPercent, lotSize, costFactor) {
    totalEquity = context["totalEquity"]
    pos = select symbol, longPosition from Backtest::getPosition(context["engine"])
    posMap = dict(pos.symbol, pos.longPosition)
    update weightsToday set targetMv = totalEquity * legPercent * costFactor * weight
    update weightsToday set px = double(each(getOpenPrice{msg}, symbol))
    update weightsToday set targetPos = iif(px <= 0., 0, iif(symbol.substr(0, 2) == "68" and floor(targetMv / px) >= 200,
        floor(targetMv / px), iif(symbol.substr(0, 2) == "68", 0, floor(floor(targetMv / px) / lotSize) * lotSize)))
    update weightsToday set curPos = nullFill(posMap[symbol], 0)
    // 先卖出:目标数量小于当前持仓,且当日未跌停、未停牌
    sell = select symbol, curPos - targetPos as num from weightsToday where targetPos - curPos < 0
    sellMap = dict(sell.symbol, sell.num)
    for (s in sellMap.keys()) {
        if (msg[s]["volume"] > 0 and sellMap[s] > 0 and isSellAble(msg[s])) {
            Backtest::submitOrder(context["engine"],
                (s, context["tradeTime"], 5, msg[s]["open"], long(sellMap[s]), 3), "卖出平仓")
        }
    }
    // 再买入:目标数量大于当前持仓,且当日未涨停、未停牌;数量不超过可用资金上限
    buy = select symbol, targetPos - curPos as num from weightsToday where targetPos - curPos > 0
    buyMap = dict(buy.symbol, buy.num)
    for (s in buyMap.keys()) {
        cash = Backtest::getAvailableCash(context["engine"])
        qty = min(getBuyAbleVolume(s, cash * (1 - context["commission"]), msg[s]["open"], lotSize), long(buyMap[s]))
        if (msg[s]["volume"] > 0 and qty > 0 and isBuyAble(msg[s])) {
            Backtest::submitOrder(context["engine"],
                (s, context["tradeTime"], 5, msg[s]["open"], qty, 1), "买入开仓")
        }
    }
}

两个细节需要注意:

  • targetPos 用行内表达式按 lotSize 取整、科创板单独分支,避免把向量运算交给用户自定义函数。

  • 买入循环里每次下单前都重新调用 Backtest::getAvailableCash,同一腿内先下的委托会占用资金;资金不足时 getBuyAbleVolume 会把数量压到上限,甚至压到 0 而不下单。卖出数量直接取当前持仓与目标的差额,不做取整。

每日回调:onBar(mutable context, msg, indicator)

msg 是当日全部标的的行情字典,键为标的代码;indicator 由接口要求保留,本策略不使用。回调先排除非调仓日,再取账户权益,然后依次处理三条腿:

def onBar(mutable context, msg, indicator) {
    rec = context["targetWeight"][date(context["tradeDate"])]
    if (typestr(rec) == "VOID") { return }              // 非调仓日不产生委托
    context["totalEquity"] = Backtest::getTotalPortfolios(context["engine"]).totalEquity[0]
    // 股票腿:100 股整数倍,目标市值中预留双边佣金与印花税
    rebalanceLeg(context, msg, rec["stockW"], rec["stockPct"], 100,
        1 - 2 * context["commission"] - context["tax"])
    // 债券 ETF 腿:100 份整数倍,免印花税
    rebalanceLeg(context, msg, rec["bondW"], rec["bondPct"], 100, 1 - 2 * context["commission"])
    // 商品 ETF 腿:100 份整数倍,免印花税
    rebalanceLeg(context, msg, rec["commodityW"], rec["commodityPct"], 100, 1 - 2 * context["commission"])
}

三条腿的差异只有三处:

  • 权重表、腿比例与成本折让(股票腿扣双边佣金与印花税;债券 ETF 腿与商品 ETF 腿只扣双边佣金,免印花税)。

  • 执行顺序是股票腿 → 债券 ETF 腿 → 商品 ETF 腿,资金在腿之间按这个顺序竞争:如果前一条腿用光了可用资金,后面的腿会被资金上限约束而少买或不买。

  • 信号来自脚本预先构造的权重表,委托在同一天按当日开盘价发出,成交由引擎按 matchingMode 撮合,不使用未来数据。

其余回调:本策略只注册 initialize 与 onBar 两个回调;权重表与腿比例通过 config["context"] 传入,不需要在回调里重新构造。

2.3 设置回测参数

config = dict(STRING, ANY)
config["startDate"] = 2012.02.01
config["endDate"] = 2022.04.28
config["strategyGroup"] = "stock"
config["cash"] = 100000000
config["commission"] = 0.0005
config["tax"] = 0.001
config["dataType"] = 4
config["matchingMode"] = 3
config["outputOrderInfo"] = true
  • startDate / endDate:DATE 类型,2012.02.01 与 2022.04.28,与行情数据的起止日期一致。

  • strategyGroup:STRING 类型,“stock”,按股票账户规则撮合与计费,账户只支持多头。

  • cash:数值字面量 100000000,引擎按 DOUBLE 处理,单位为元,即初始资金 1 亿元。

  • commission:DOUBLE 类型,0.0005,买卖双向费率;同一值同时写入上下文,供成本折让使用。

  • tax:DOUBLE 类型,0.001,卖出印花税率,只作用于股票腿。

  • dataType:INT 类型,4,输入日频行情并触发 onBar。

  • matchingMode:INT 类型,3,以委托价成交,本策略传入的委托价是当日开盘价。

  • outputOrderInfo:BOOL 类型,true,成交明细中保留订单状态信息。

随后是分红除权事件与标的池的配置:

// 分红除权事件:模拟样本内不设事件,接入真实环境时改为读取真实分红除权表
stockDividend = table(1:0, `symbol`endDate`annDate`recordDate`exDate`payDate`divListDate`bonusRatio`capitalConversion`afterTaxCashDiv`allotPrice`allotRatio,
    [SYMBOL, DATE, DATE, DATE, DATE, DATE, DATE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE])
config["stockDividend"] = stockDividend

// 标的池:按腿划分;11 只标的全等权,腿比例由腿内标的权重之和自然得出(5/11、3/11、3/11)
context = dict(STRING, ANY)
context["stockLeg"] = ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ", "000005.SZ"]
context["bondLeg"] = ["510001.SH", "510002.SH", "510003.SH"]
context["commodityLeg"] = ["518880.SH", "159980.SZ", "159985.SZ"]

stockDividend 是引擎要求的分红除权输入。模拟样本内不含分红事件,这里给出一张列名与类型正确的空表;接入真实行情时需要换成真实的分红除权表(股票腿的除权跳空、ETF 的分红都会影响持仓与现金)。标的池用三张代码清单显式声明三条腿,避免用代码前缀猜——例如 518880.SH 也以 51 开头,用前缀规则会与债券 ETF 混淆。

2.4 调仓记录与权重表

权重表分两步构造:先按「交易日 × 标的」生成一日一行、全标的等权的权重(11 只各 1/11),再按自然月截取每月首个交易日,得到 123 条调仓记录;每条记录再拆成三条腿的腿内权重表与腿比例:

w = select tradeDay, symbol, 1.0 \ size(symbol) as weight
    from (select 1.0 as w from dailyData group by tradeTime.date() as tradeDay, symbol)
    context by tradeDay
w = select * from w context by month(tradeDay) having tradeDay == min(tradeDay) order by tradeDay

dailyWeight = dict(DATE, ANY, true)
for (d in sort(distinct(w.tradeDay))) {
    s = select symbol, weight from w where tradeDay = d
    rec = dict(STRING, ANY)
    rec["stockW"] = select symbol, weight \ sum(weight) as weight from s where string(symbol) in context["stockLeg"]
    rec["bondW"] = select symbol, weight \ sum(weight) as weight from s where string(symbol) in context["bondLeg"]
    rec["commodityW"] = select symbol, weight \ sum(weight) as weight from s where string(symbol) in context["commodityLeg"]
    rec["stockPct"] = exec sum(weight) from s where string(symbol) in context["stockLeg"]
    rec["bondPct"] = exec sum(weight) from s where string(symbol) in context["bondLeg"]
    rec["commodityPct"] = exec sum(weight) from s where string(symbol) in context["commodityLeg"]
    dailyWeight[d] = rec
}
context["targetWeight"] = dailyWeight
context["commission"] = config["commission"]
context["tax"] = config["tax"]
config["context"] = context
  • targetWeight:DICT 类型,键为调仓日(DATE),值为包含 stockW、bondW、commodityW(三张腿内权重表)与 stockPct、bondPct、commodityPct(三条腿比例)的记录。

  • stockLeg / bondLeg / commodityLeg:STRING 向量,三条腿的标的清单。

  • commission / tax:与 config 中同名项一致,供单腿再平衡计算成本折让。

调仓频率在这里控制:把 context by month(...) 换成 week(...) 就是周频,换成 year(...) 就是年频。若想改腿间比例(例如股票 40% / 债券 30% / 商品 30%),把 rec["stockPct"] 等三行改成固定数值,并让腿内权重之和为 1 即可。

2.5 创建回测引擎

callbacks = dict(STRING, ANY)
callbacks["onBar"] = onBar

engineName = "assetAllocationStrategy"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)

引擎名称在同一会话内不能重复,创建前先用 Backtest::dropBacktestEngine 清理同名引擎。

2.6 执行回测

Backtest::appendQuotationMsg(engine, dailyData)
Backtest::appendEndMarker(engine)

appendQuotationMsg 把 27,412 行日线行情按 tradeTime 顺序推给引擎,每个交易日触发一次 onBar,共 2,492 个交易日;appendEndMarker 表示行情结束、引擎开始收尾。权重表与腿比例在创建引擎之前已经写入上下文,因此这两行执行时策略参数已就绪。

2.7 获取回测结果

tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
  • tradeDetails:委托与成交明细,含 orderId、symbol、direction、sendTime、orderPrice、orderQty、tradeTime、tradePrice、tradeQty、orderStatus、label、orderInfo。一行委托不一定全部成交,统计成交必须先过滤 tradeQty > 0。

  • returnSummary:收益概述,含 totalReturn、annualReturn、annualVolatility、sharpeRatio、maxDrawdown、turnoverRate、dailyWinningRate 等;未设置基准时 beta、alpha、annualExcessReturn、benchmarkReturn 为 0。

  • dailyPosition:逐日逐标的持仓,共 27,412 行,可核对每个调仓日之后三条腿各自的实际持仓数量。

  • totalPortfolios:逐日组合权益、持仓市值与可用资金,共 2,492 行。

3. 回测结果分析

本节结果来自 DolphinDB Server 3.00.6 上的实际运行。行情为上述模拟日线(27,412 行、11 只标的、每只 2,492 个交易日,2012-02-01 至 2022-04-28);三条腿共用一个股票账户,初始资金 1 亿元,佣金率 0.0005 双边收取,股票腿印花税 0.001,日频行情逐个交易日触发回调、按委托价以当日开盘价成交,调仓频率为每月首个交易日,11 只标的全等权(股票腿 5/11、债券 ETF 腿 3/11、商品 ETF 腿 3/11)。

表 3. 表 3-1 回测结果概览
指标 数值
区间总收益 +6.3001%
年化收益 +0.6197%
年化波动率 5.3264%
最大回撤 12.0097%
夏普比率 0.1164
换手率 0%
日胜率 50.64%

成交方面,委托与成交明细共 2,606 行,其中实际成交 1,303 笔,标签为「买入开仓」669 笔、「卖出平仓」634 笔;逐日持仓 27,412 行、逐日权益 2,492 行,与 11 只标的、2,492 个交易日对应。按腿汇总的成交笔数如下:

表 4. 表 3-2 各资产腿成交汇总表
腿 成交笔数 成交数量合计
股票腿 580 12,432,200
债券 ETF 腿 358 9,028,500
商品 ETF 腿 365 9,188,700

三条腿都产生了持续成交,说明三腿拆分、腿比例计算、腿内先卖后买与各自的下单规则都按设计生效:股票腿成交笔数最多,因为它有 5 只标的(腿内标的越多,单只标的的目标市值变动越容易被触发);债券 ETF 腿与商品 ETF 腿各 3 只,笔数接近。这也从侧面验证了标的池显式声明的必要性——如果按代码前缀区分腿,518880.SH 会被误判进债券腿。

收益与风险指标要放在模拟数据的背景下阅读。10 年区间总收益 +6.3001% 折算年化仅 +0.6197%,年化波动率 5.3264%,说明这是一条几乎贴着零收益波动的净值曲线:模拟数据生成的价格是随机游走,三条腿之间没有真实的资产类别收益差与相关性结构,等权配置在这里赚不到真实市场中股票的风险溢价、也得不到债券与商品对股票下行时的对冲效果。同时,10 年 123 次调仓持续产生双边佣金与股票腿印花税,随机价格没有趋势可以覆盖这部分成本。

还需要说明三处口径:

  • 本策略未设置基准,turnoverRate 与基准类字段(beta、alpha、annualExcessReturn、benchmarkReturn)为 0,不能解读为某个指数的表现。

  • 商品腿用商品 ETF 表达,没有期货的保证金占用与双向持仓,若关心保证金与做空能力需要用 FICC 多资产账户重做。

  • 分红除权表在样本内为空,除权除息、ETF 分红对持仓与现金的影响没有经过验证。

4. 总结

大类资产配置回测策略把 1 亿元资金按固定比例分散到三类资产:股票腿 5 只股票(000001.SZ – 000005.SZ)、债券 ETF 腿 3 只(510001.SH – 510003.SH)、商品 ETF 腿 3 只(518880.SH、159980.SZ、159985.SZ),11 只标的全等权,因此三条腿的比例自然形成 5/11、3/11、3/11;调仓日取每月首个交易日,当日按开盘价先卖后买,数量按 100 股/份整数倍取整(科创板 200 股起),股票腿扣双边佣金与印花税、债券与商品 ETF 腿免印花税,并受涨停不买、跌停不卖、停牌不动三个条件约束。行情是模拟数据,收益与回撤只能用于流程验证;商品腿以商品 ETF 表达,并非真实商品期货;分红与付息事件在样本内为空表,真实研究时应替换为真实日线行情与真实分红事件表。

使用时需要注意三点限制:

  • 行情是模拟数据,价格由随机字段重建,三条腿之间没有真实的收益与相关性结构,本次收益与回撤只是流程验证的产物,不能外推。

  • 商品腿按“股票账户可交易的场内标的”实现为商品 ETF,如需真实的商品期货(保证金、双向、合约乘数)应改用 FICC 多资产账户或全期货账户。

  • 分红与付息事件在样本内为空表,股票除权与 ETF 分红对持仓与现金的影响未经验证;用于真实研究时应替换为真实日线行情与真实分红事件表,并重新评估成本与样本外表现。