股票组合融资杠杆管理策略
本策略以外部生成的每日目标权重为输入,在每个交易日计算目标持仓,并通过 DolphinDB Backtest 引擎提交调仓订单。目标权重本身如何生成不属于本策略的实现范围,因此回测结果只能评价给定权重在指定执行规则下的表现,不能单独证明权重模型有效。
1. 策略介绍
本策略不在回测过程中选股或计算因子,而是读取已经生成的权重文件。每个交易日,策略以账户总权益为基数,将当日权重换算为目标市值和目标股数,再按照先卖后买的顺序调整组合。
1.1 核心思路
设交易日为 t,账户总权益为 E_t,股票 i 的目标权重为 w_{i,t},手续费率为 c,印花税率为 s。按下式计算目标市值:
其中,1-2c-s 为预留的一次买入手续费、一次卖出手续费和一次卖出印花税。随后使用当日开盘价将目标市值换算为目标股数。普通股票的目标股数向下取整到 100 股的整数倍;代码以 68 开头的股票识别科创板标的,首次买入数量不足 200 股时不下单,达到 200 股后允许按 1 股递增。
这是一种目标权重跟踪策略。权重文件负责表达“希望持有什么”,回测脚本负责把目标权重转换为订单,并受价格、成交量、涨跌停状态和可用现金约束。
1.2 交易标的
配套行情文件包含 .SH 后缀的上交所股票代码,行情频率为日频。策略使用普通股票账户,只建立或减少多头持仓,不包含卖空、融资或融券逻辑。
行情文件覆盖 2025-01-02 至 2025-06-30,共 117 个交易日、232,196 行记录和 1,998 个唯一标的。权重文件覆盖相同的 117 个交易日,共 32,871 行记录和 283 个唯一标的。每个交易日有 259 至 283 个正权重标的,日权重之和在浮点误差范围内等于 1。
这些文件用于演示回测流程。文档没有权重模型的生成逻辑、历史可用时点或基准信息,因此不能把权重解释为某个指数、因子组合或投资建议。用于真实研究时,必须确认交易日 t 的权重在当日开盘前已经可用,否则会产生未来数据问题。
1.3 交易规则
策略在每个有目标权重的交易日按以下顺序运行:
-
从回测引擎读取当前账户总权益、可用现金和各股票多头持仓。
-
根据当日权重、交易成本预留和当日开盘价计算目标股数。
-
对目标股数低于当前持仓的股票先提交卖出平仓订单。
-
对目标股数高于当前持仓的股票再提交开仓买入订单。
-
买入数量取目标差额与可用现金可承担数量的较小值,并应用普通股票整手和科创板最低申报数量规则。
-
成交量不大于 0,或收盘价等于相应涨跌停价时,脚本跳过订单。
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
案例使用 portfolioData.csv 作为日频行情输入,使用 weight.csv 作为每日目标权重输入。运行脚本前,应将相对路径改为 DolphinDB Server 进程实际可访问的路径。CSV 文件本身不保存 DolphinDB 的 TIMESTAMP、LONG 或数组向量类型信息,因此行情文件通过显式 schema 加载;权重文件由 loadText 推断类型,加载后应检查并统一 tradeDay 的日期类型。
| 文件 | 用途 | 数据范围 | 行数 | 唯一标的数 |
|---|---|---|---|---|
portfolioData.csv |
日频行情与扩展信号 | 2025-01-02 至 2025-06-30,共 117 个交易日 | 232,196 | 1,998 |
weight.csv |
每日目标权重 | 32,871 | 283 |
| 列名 | DolphinDB 类型 | 说明 |
|---|---|---|
| symbol | SYMBOL | 股票代码,带 .SH 后缀 |
| tradeTime | TIMESTAMP | 行情所属交易日的零点时间戳 |
| open | DOUBLE | 开盘价,单位为元/股;策略用作目标数量计算和委托价格 |
| low | DOUBLE | 当日最低价,单位为元/股 |
| high | DOUBLE | 当日最高价,单位为元/股 |
| close | DOUBLE | 收盘价,单位为元/股;当前脚本还用它判断涨跌停 |
| volume | LONG | 成交量;不大于 0 时不提交订单 |
| amount | DOUBLE | 成交金额,单位为元 |
| upLimitPrice | DOUBLE | 当日涨停价,单位为元/股 |
| downLimitPrice | DOUBLE | 当日跌停价,单位为元/股 |
| prevClosePrice | DOUBLE | 前收盘价,单位为元/股 |
| signal | DOUBLE[] | 扩展信号数组;本策略逻辑没有读取该字段 |
| 列名 | DolphinDB 类型 | 说明 |
|---|---|---|
| tradeDay | DATE | 目标权重生效日;CSV 中保存为带纳秒部分的时间文本,加载后应检查并转换为 DATE |
| symbol | SYMBOL | 股票代码,带 .SH 后缀 |
| weight | DOUBLE | 目标权重,取值大于 0;同一交易日的权重之和约为 1 |
目标脚本使用以下代码加载两份数据,并把权重表转换为以 DATE 为键的字典:
path = "/.../"
w = loadText(path + "weight.csv")
w = select * from w where weight > 0. order by tradeDay
dailyWeight = dict(DATE, ANY, true)
for (idate in sort(distinct(w.tradeDay))) {
s = select symbol, weight from w where tradeDay = idate
dailyWeight[idate] = s
}
name = ["symbol", "tradeTime", "open", "low", "high", "close", "volume",
"amount", "upLimitPrice", "downLimitPrice", "prevClosePrice", "signal"]
type = ["STRING", "TIMESTAMP", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "LONG",
"DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE[]"]
dailyData = loadText(path + "portfolioData.csv", schema=table(name as name, type as type))
2.2 编写策略逻辑
策略由 5 个辅助函数和一个 onBar 回调组成。辅助函数负责交易条件检查、数量取整和开盘价读取;onBar 负责把当日目标权重转换为目标头寸并提交订单。
交易条件函数:isBuyAble 与 isSellAble
isBuyAble(data) 和 isSellAble(data) 接收单只股票的日频行情记录,返回 BOOL 标量。前者在成交量不大于 0 或收盘价等于涨停价时返回 false,后者在成交量不大于 0 或收盘价等于跌停价时返回 false。函数不修改账户状态。
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
}
数量处理函数:checkBuyVolume 与 getBuyAbleVolume
-
checkBuyVolume(istock, num)将目标持仓差额转换为符合脚本规则的买入数量。 -
getBuyAbleVolume(istock, cash, price)根据可用现金和价格计算最多可买数量。
两者都返回非负数量;普通股票按 100 股向下取整,68 开头的股票要求首次数量至少为 200 股。
def checkBuyVolume(istock, num) {
if (num <= 0) { return 0 }
if (istock.substr(0, 2) == "68" and num >= 200) {
return num
} else if (istock.substr(0, 2) == "68") {
return 0
}
return floor(int(num) / 100) * 100
}
def getBuyAbleVolume(istock, cash, price) {
if (price <= 0.) { return 0 }
if (istock.substr(0, 2) == "68" and floor(cash / price) >= 200) {
return floor(cash / price)
} else if (istock.substr(0, 2) == "68") {
return 0
}
return floor(floor(cash / price) / 100) * 100
}
开盘价读取函数:getOpenPrice
getOpenPrice(msg, istock) 从当前交易日的行情字典中读取指定股票的开盘价。函数返回 open 字段对应的 DOUBLE 标量,不修改行情、权重表或账户状态。该函数在 onBar 计算目标持仓之前调用。
def getOpenPrice(msg, istock) {
return msg[istock]["open"]
}
日频回调函数:onBar
onBar(mutable context, msg, indicator) 在日频行情进入引擎时调用。
-
context 是可变 DICTIONARY,包含引擎句柄、当前交易日期和时间,以及脚本写入的权重字典和成本参数。
-
msg 是以股票代码为键、以当日行情记录为值的 DICTIONARY。
-
indicator 是引擎预留的指标结果参数,本策略没有读取它。
函数没有显式返回值,主要副作用是向引擎提交订单。执行顺序为读取当日权重、获取权益与持仓、计算目标持仓、先提交卖单,再根据可用现金提交买单。
def onBar(mutable context, msg, indicator) {
weightsToday = context["targetWeight"][date(context["tradeDate"])]
idate = date(context["tradeDate"])
if (typestr(weightsToday) == "VOID") { return }
totalEquity = Backtest::getTotalPortfolios(context["engine"]).totalEquity[0]
pos = select symbol, longPosition from Backtest::getPosition(context["engine"])
pos = dict(pos.symbol, pos.longPosition)
update weightsToday
set targetMv = totalEquity *
(1 - 2 * context["commission"] - context["tax"]) * weight
update weightsToday set open = each(getOpenPrice{msg}, symbol)
update weightsToday
set targetPos = iif(open <= 0., 0,
iif(symbol.substr(0, 2) == "68" and floor(targetMv / open) >= 200,
floor(targetMv / open),
iif(symbol.substr(0, 2) == "68", 0,
floor(floor(targetMv / open) / 100) * 100)))
update weightsToday set Pos = nullFill(pos[symbol], 0)
sell = select symbol, Pos - targetPos as num
from weightsToday where targetPos - Pos < 0
sell = dict(sell.symbol, sell.num)
for (istock in sell.keys()) {
data = msg[istock]
num = sell[istock]
if (isSellAble(data) and num > 0) {
Backtest::submitOrder(
context["engine"],
(istock, context["tradeTime"], 5, msg[istock].open, num, 3),
"卖出平仓")
}
}
buy = select symbol, targetPos - Pos as num
from weightsToday where targetPos - Pos > 0
buy = dict(buy.symbol, buy.num)
for (istock in buy.keys()) {
data = msg[istock]
num = buy[istock]
cash = Backtest::getAvailableCash(context["engine"])
price = data.open
qty = getBuyAbleVolume(istock, cash * (1 - context["commission"]), price)
num = min(checkBuyVolume(istock, num), qty)
if (isBuyAble(data) and num > 0) {
Backtest::submitOrder(
context["engine"],
(istock, context["tradeTime"], 5, price, int(num), 1),
"开仓买入")
}
}
}
2.3 设置回测参数
脚本使用 config 配置回测区间、账户、成本、行情和撮合模式,并通过 context 向回调传递每日权重和成本参数。
config = {
startDate: 2025.01.01,
endDate: 2025.07.01,
strategyGroup: "stock",
cash: 100000000,
commission: 0.0005,
tax: 0.001,
dataType: 4,
matchingMode: 3,
outputOrderInfo: true
}
context = {
targetWeight: dailyWeight,
commission: config["commission"],
tax: config["tax"]
}
config["context"] = context
| 参数 | 类型 | 取值 | 说明 |
|---|---|---|---|
| startDate | DATE | 2025-01-01 | 回测区间起点;配套行情从 2025-01-02 开始 |
| endDate | DATE | 2025-07-01 | 回测区间终点;配套行情截至 2025-06-30 |
| strategyGroup | STRING | stock | 使用普通股票策略模式 |
| cash | INT | 100,000,000 | 初始资金,单位为元 |
| commission | DOUBLE | 0.0005 | 单边手续费率 0.05% |
| tax | DOUBLE | 0.001 | 卖出印花税率 0.1% |
| dataType | INT | 4 | 使用日频行情 |
| matchingMode | INT | 3 | 按委托价格成交;本策略以 open 作为委托价格 |
| outputOrderInfo | BOOL | true | 输出订单状态信息 |
| context.targetWeight | DICTIONARY | dailyWeight | 按 DATE 索引每日目标权重表 |
| context.commission | DOUBLE | 0.0005 | 回调函数计算目标市值和可买数量时使用的手续费率 |
| context.tax | DOUBLE | 0.001 | 回调函数预留卖出印花税时使用的税率 |
commission 和 tax 同时保存在 config 与 context 中。前者供回测引擎计算实际交易费用,后者供策略回调计算目标市值和可买数量;调整成本参数时必须保持两处一致。
2.4 创建回测引擎
脚本先加载 Matching Engine Simulator 和 Backtest 插件,再将 onBar 注册为日线回调。loadPlugin 只加载已经安装的插件,不会自动安装缺失插件。
try { loadPlugin("MatchingEngineSimulator") } catch(ex) { print ex }
try { loadPlugin("Backtest") } catch(ex) { print ex }
go
strategyName = "Backtest_test1"
callbacks = dict(STRING, ANY)
callbacks["onBar"] = onBar
try { Backtest::dropBacktestEngine(strategyName) } catch(ex) { print ex }
engine = Backtest::createBacktester(strategyName, config, callbacks,false)
脚本将 createBacktester 的第四个参数显式设置为 false,以非 JIT 模式运行。本案例的实际结果也在该模式下取得。
2.5 执行回测
行情表传入 Backtest::appendQuotationMsg 后,引擎按交易日触发 onBar。全部行情处理完成后,脚本调用 Backtest::appendEndMarker 结束回测并触发引擎收尾处理。
Backtest::appendQuotationMsg(engine, dailyData)
Backtest::appendEndMarker(engine)
只有在结束标记处理完成后才能读取完整结果。重复运行前应删除旧引擎并重新创建,避免复用已有账户状态。
2.6 获取回测结果
目标脚本查询交易明细、每日持仓、每日账户权益和收益概述:
tradeDetails = Backtest::getTradeDetails(engine)
dailyPosition = Backtest::getDailyPosition(engine)
dailyPortfolios = Backtest::getDailyTotalPortfolios(engine)
returnSummary = Backtest::getReturnSummary(engine)
-
tradeDetails 同时包含订单的不同状态行,不能把每一行都计为一笔成交。分析时应根据 orderId 去重订单,并结合 orderStatus、tradeQty 和未完成数量区分订单与实际成交。
-
dailyPosition 用于检查每日持仓。
-
dailyPortfolios 用于分析账户权益、净值和费用。
-
returnSummary 用于汇总收益和风险。
脚本没有设置真实基准,因此不能把基准字段解释为某个指数,也不能据此宣称获得超额收益。
3. 回测结果分析
本策略已使用配套 CSV 在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 和 Matching Engine Simulator 3.00.6.6 环境中完整执行。回测覆盖 2025-01-02 至 2025-06-30,共处理 117 个交易日和 232,196 行行情,初始资金为 100,000,000 元。
| 指标 | 实际结果 |
|---|---|
| 期末总权益 | 93,257,742.76 元 |
| 期末现金 | 4,579,092.16 元 |
| 期末持仓市值 | 88,678,650.60 元 |
| 区间总收益率 | -6.7423% |
| 年化收益率 | -13.9589% |
| 年化波动率 | 16.7040% |
| 夏普比率 | -0.8357 |
| 最大回撤 | 11.1195% |
| 日胜率 | 49.5726% |
| 累计交易费用 | 6,317,065.36 元 |
| 期末持仓股票数 | 241 |
| 期末持仓股数 | 9,604,763 股 |
初始资金为 1 亿元,期末总权益为 9,325.77 万元,区间亏损 674.23 万元。累计交易金额约为 63.62 亿元,交易费用达到 631.71 万元,是影响结果的重要因素;这与策略按日再平衡、订单数量较多的特征一致。收益概述没有配置真实基准,返回的基准收益、Alpha 和 Beta 不具备指数比较含义。
最后一个交易日共有 259 个目标权重标的,其中只有 223 个具有当日行情。期末实际持有 241 只股票,其中 21 只已经不在最后一日目标集合中,市值合计为 8,140,140.07 元,约占期末总权益的 8.73%。这说明当前调仓逻辑只处理当日权重表内的股票,未对退出目标集合的历史持仓统一清仓,实际组合与目标权重存在偏离。
以上结果基于案例 CSV 和给定执行规则,只用于验证策略实现与回测流程。输入权重的生成方法和历史可用时点没有提供,且开盘委托的交易条件使用了同一交易日的收盘信息,因此不能把本次收益解释为真实可交易策略的历史表现,也不能据此判断权重模型有效或无效。
4. 总结
本案例展示了如何把每日目标权重转换为日频股票组合的目标持仓,并通过 DolphinDB Backtest 引擎按先卖后买的顺序提交调仓订单。脚本包含成本预留、普通股票整手、科创板最低申报数量、停牌和涨跌停过滤以及可用现金约束,适合说明权重驱动型组合回测的基本结构。
在配套数据上,策略完成了 117 个交易日和 232,196 行行情的处理,产生 27,800 个订单,期末总权益为 93,257,742.76 元,区间收益率为 -6.7423%。实际结果同时表明,按日调仓产生的交易费用较高,缺失行情和退出目标集合后未清仓会造成持仓偏离。用于真实投资研究前,应明确权重可用时点,统一行情覆盖范围,并完善目标集合退出、未成交和交易时点处理。
5. 附录
以下为本策略的完整策略脚本(包含策略函数、回测配置和引擎执行流程)以及配套的模拟行情与权重数据文件:
