股票投资组合交易策略
中低频策略通常基于日频或分钟级别的数据,通过分析市场趋势、估值指标、风险因子等,捕捉更长时间尺度上的投资机会。策略的核心在于设计一套科学有效的选股与仓位管理机制,从而实现收益最大化与风险控制。相比高频交易的短线博弈,中低频交易更关注策略在复杂市场条件下的适应性与风险控制,追求更高的资金利用效率和更稳定的回报。
本文介绍一个分钟频交易策略:使用均线、相对强弱指数(Relative Strength Index,RSI)和波动率构建股票组合,并在固定时刻根据目标市值调仓。
1. 策略介绍
近年来,金融市场的复杂性和不确定性显著增加,投资者面临着市场波动性增强、指数与个股联动性加剧以及多因子模型应用场景多样化的挑战。在此背景下,基于指数信号的股票投资组合交易策略应运而生,该策略通过结合指数信号的市场趋势分析和个股多因子筛选,旨在解决投资组合管理中仓位动态调整、个股筛选优化和风险控制的复杂难题。
1.1 核心因子
本策略同时使用趋势、动量和风险三类因子。均线交叉与 RSI 决定股票进入候选池还是剔除池,波动率不直接决定买卖方向,而是对入选股票的目标权重进行折减。
| 因子 | 计算口径 | 信号方向 | 用途 |
|---|---|---|---|
| 短期均线 | 最近 5 个分钟周期收盘价的移动平均 | 短期均线上穿长期均线时形成买入候选;下穿时形成剔除候选 | 识别短周期趋势变化 |
| 长期均线 | 最近 20 个分钟周期收盘价的移动平均 | 与短期均线比较,不单独生成信号 | 提供中期趋势基准 |
| RSI | 24 周期相对强弱指数 | RSI 小于 70 时形成买入候选;RSI 大于 80 时形成剔除候选 | 补充候选筛选,并在 RSI 回落至 70 以下时重置风控状态 |
| 波动率 | 最近 10 个周期的收益率标准差;收益率为相邻收盘价之差除以前一期收盘价 | 0.005 至 0.01 时权重乘以 0.5;大于 0.01 时权重乘以 0.1 | 降低高波动股票的目标权重 |
1.2 交易标的
策略中的 000001.XSHG 至 000010.XSHG 是模拟数据。随机价格没有连续收益过程、真实复权关系或真实成交机制,模拟股票也不代表历史沪深 300 成分股。
策略只建立多头现金持仓,不处理分红、复权、ST、上市天数或 T+1 可卖数量;税率设置为 0。把策略迁移到真实数据时,需要补充历史成分股、复权、停牌、涨跌停、手续费和信息实际可用时间等规则。
1.3 交易规则
本策略使用 10 只模拟股票的分钟行情。数据脚本预先计算 5 周期短期均线、20 周期长期均线、24 周期 RSI 和 10 周期波动率,并把指标写入行情表的 signal 数组向量。策略仅在每天 14:30 执行一次选股与调仓。
策略的设计意图是通过均线交叉和 RSI 生成候选股票,再根据波动率调整权重:
-
短期均线上穿长期均线,或者 RSI 小于 70 时,将股票加入候选买入列表。
-
短期均线下穿长期均线,或者 RSI 大于 80 时,将股票加入剔除列表。
-
用候选买入列表减去剔除列表,得到当期目标股票池。
-
对目标股票先等权分配;波动率介于 0.005 和 0.01 之间时,把权重乘以 0.5;波动率大于 0.01 时,把权重乘以 0.1。
-
把单只股票权重限制在 0.001 至 0.1 之间,再将全部目标权重归一化为 1。
-
根据账户总权益计算目标市值,与现有持仓市值比较后先卖出、再买入。
策略将普通股票买入数量按 100 股的整数倍向下取整,将代码以 688 开头的股票按 200 股的整数倍向下取整。卖出数量按 100 股的整数倍计算;如果计划卖出量达到现有持仓,则直接使用全部持仓数量。
2. 策略回测
下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
下载附录中的 stock_portfolio.csv 文件。运行前需要把相应文件放到 DolphinDB Server 可访问的位置。CSV 文件不保存 DolphinDB 的 TIMESTAMP、DATE、LONG 或数组向量类型,因此脚本使用显式 schema 导入,并恢复回测接口需要的 signal。模拟数据仅用于验证流程,不能用于判断策略收益能力。
stockCsvPath = "/tmp/quant_tutorial_stock_portfolio.csv"
stockCsvName = `symbol`tradeTime`open`low`high`close`volume`amount`upLimitPrice`downLimitPrice`prevClosePrice`turnover`RSI`longMA`shortMA`volatility`prevShortMA`prevLongMA
stockCsvType = ["STRING", "TIMESTAMP", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE",
"LONG", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE",
"DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"]
stockCsvSchema = table(stockCsvName as name, stockCsvType as type)
stockCsvTable = loadText(filename=stockCsvPath, schema=stockCsvSchema)
update stockCsvTable set signal = fixedLengthArrayVector(
[turnover, RSI, longMA, shortMA, volatility, prevShortMA, prevLongMA])
messageTable = select symbol, tradeTime, open, low, high, close, volume, amount,
upLimitPrice, downLimitPrice, prevClosePrice, signal
from stockCsvTable order by tradeTime, symbol
stock_portfolio.csv 包含 000001.XSHG 至 000010.XSHG 共 10 个模拟标的、2,546,400 条数据记录,时间范围为 2020-07-03 09:31:00 至 2024-11-15 15:00:00,本地文件大小为 766,221,260 字节。每个交易日包含 09:31 至 11:30、13:01 至 15:00 的分钟记录。回测配置从 2024-01-01 开始,因此 2024 年以前的数据主要用于在回测开始前形成均线、RSI 和波动率的历史窗口。
| 列名 | 类型 | 说明 |
|---|---|---|
| symbol | STRING | 模拟股票代码,范围为 000001.XSHG 至 000010.XSHG |
| tradeTime | TIMESTAMP | 交易所日历中的分钟时间 |
| open | DOUBLE | 随机生成的分钟开盘价,单位为元/股 |
| low | DOUBLE | 随机生成的分钟最低价,单位为元/股 |
| high | DOUBLE | 随机生成的分钟最高价,单位为元/股 |
| close | DOUBLE | 随机生成的分钟收盘价,单位为元/股 |
| volume | LONG | 随机生成的分钟成交量 |
| amount | DOUBLE | 随机生成的分钟成交额 |
| upLimitPrice | DOUBLE | 前一条收盘价乘以 1.1 |
| downLimitPrice | DOUBLE | 前一条收盘价乘以 0.9 |
| prevClosePrice | DOUBLE | 使用 move(close, 1) 得到的前一条收盘价 |
| turnover | DOUBLE | 随机生成的换手率,用于交易状态检查 |
| RSI | DOUBLE | RSI(close, 24) 的计算结果 |
| longMA | DOUBLE | MA(close, 20) 的计算结果 |
| shortMA | DOUBLE | MA(close, 5) 的计算结果 |
| volatility | DOUBLE | STD(DIFF(close) / REF(close, 1), 10) 的计算结果 |
| prevShortMA | DOUBLE | 前一期短期均线,空值向后填充 |
| prevLongMA | DOUBLE | 前一期长期均线,空值向后填充 |
CSV 将七个指标保存为标量列。回测脚本导入后按下表顺序重建 signal 固定长度数组向量。
| 位置 | 指标 | 计算方式 |
|---|---|---|
| signal[0] | 换手率 | 随机生成,用于交易状态检查 |
| signal[1] | RSI | RSI(close, 24) |
| signal[2] | 长期均线 | MA(close, 20) |
| signal[3] | 短期均线 | MA(close, 5) |
| signal[4] | 波动率 | STD(DIFF(close) / REF(close, 1), 10) |
| signal[5] | 前一期短期均线 | prev(shortMA).bfill() |
| signal[6] | 前一期长期均线 | prev(longMA).bfill() |
2.2 编写策略逻辑
股票组合回调在固定分钟计算目标股票和目标市值,然后调用辅助函数提交股票订单。脚本使用行情回调时点的当期 close 提交订单,matchingMode=3 表示按委托价格成交。
2.2.1 股票交易状态、均线判断与订单数量
checkTradingStatus 在价格、成交量或换手率为 0,以及收盘价触及涨跌停价时返回 0。getCross 接收两条均线的当前值与前值,返回上穿和下穿两个 BOOL 标量。getSellVolume 与 getBuyVolume 负责把市值差转换为下单股数。
def checkTradingStatus(open, high, low, volume, turnover, close,
upLimitPrice, downLimitPrice){
isHalted = (open == 0 || high == 0 || low == 0 ||
volume == 0 || turnover == 0)
isPriceLimited = (close >= upLimitPrice || close <= downLimitPrice)
return iif(isHalted || isPriceLimited, 0, 1)
}
def getCross(firstMA, secondMA, prevFirstMA, prevSecondMA){
isCrossUp = (prevFirstMA <= prevSecondMA) && (firstMA > secondMA)
isCrossDown = (prevFirstMA >= prevSecondMA) && (firstMA < secondMA)
return (isCrossUp, isCrossDown)
}
def getSellVolume(price, value, position){
vol = int(value / price) / 100 * 100
return int(iif(vol < position, vol, position))
}
def getBuyVolume(symbol, price, value){
vol = int(value / price)
return int(iif(symbol like "688%", vol / 200 * 200, vol / 100 * 100))
}
-
firstMA:数值标量,当前短期均线。
-
secondMA:数值标量,当前长期均线。
-
prevFirstMA:数值标量,预期为前一期短期均线。
-
prevSecondMA:数值标量,预期为前一期长期均线。
-
price:数值标量,下单价格,单位为元/股。
-
value:数值标量,目标市值与当前市值差额,单位为元。
-
position:数值标量,当前多头持仓股数。
-
symbol:STRING 标量,股票代码。
onBar 当前调用为 getCross(signal[3], signal[2], signal[6], signal[5]),即把 prevLongMA 传给 prevFirstMA、把 prevShortMA 传给 prevSecondMA。
2.2.2 股票组合调仓回调
onBar(mutable context, msg, indicator) 是股票策略的核心回调。
context 是可变 DICTIONARY,包含当前时间、引擎句柄、成本和风控状态;
msg 是以股票代码为键的分钟行情字典;
indicator 是接口保留参数,本策略没有读取它。函数没有显式返回值,副作用是更新风控字典并提交订单。
核心回调按脚本实际顺序如下:
def onBar(mutable context, msg, indicator){
if (context["activeTime"] != minute(context["tradeTime"])) {
return
}
buyList = array(SYMBOL)
deleteList = array(SYMBOL)
for (istock in msg.keys()){
tradeFlag = checkTradingStatus(
msg[istock]["open"], msg[istock]["high"],
msg[istock]["low"], msg[istock]["volume"],
msg[istock]["signal"][0], msg[istock]["close"],
msg[istock]["upLimitPrice"], msg[istock]["downLimitPrice"])
if (getCross(msg[istock]["signal"][3], msg[istock]["signal"][2],
msg[istock]["signal"][6], msg[istock]["signal"][5])[0]
or msg[istock]["signal"][1] < 70 and tradeFlag) {
buyList.append!(istock)
}
if (getCross(msg[istock]["signal"][3], msg[istock]["signal"][2],
msg[istock]["signal"][6], msg[istock]["signal"][5])[1]
or msg[istock]["signal"][1] > 80 and tradeFlag) {
deleteList.append!(istock)
}
}
posList = (set(buyList) - set(deleteList)).keys()
if (count(posList) != 0) {
weightDict = dict(SYMBOL, DOUBLE)
for (istock in posList) {
weightDict[istock] = 1 \ posList.size()
volatility = msg[istock]["signal"][4]
rsi = msg[istock]["signal"][1]
if (context["riskDict"][istock] == 1 && rsi < 70) {
context["riskDict"][istock] = 0
}
if (0.005 < volatility < 0.01) {
weightDict[istock] *= 0.5
context["riskDict"][istock] = 1
}
else if (volatility > 0.01) {
weightDict[istock] *= 0.1
context["riskDict"][istock] = 1
}
weightDict[istock] = min(weightDict[istock], context["maxPosition"])
weightDict[istock] = max(weightDict[istock], context["minPosition"])
}
weightSum = sum(weightDict.values())
if (weightSum != 0) {
for (istock in posList) {
weightDict[istock] /= weightSum
}
}
equity = Backtest::getTotalPortfolios(context["engine"])["totalEquity"][0]
sellDeltaValueDict = dict(SYMBOL, DOUBLE)
buyDeltaValueDict = dict(SYMBOL, DOUBLE)
for (istock in posList) {
expectValue = equity * weightDict[istock]
longPosition = Backtest::getPosition(
context["engine"], istock)["longPosition"]
positionValue = iif(longPosition == NULL,
0.0,
longPosition * msg[istock]["close"])
deltaValue = expectValue - positionValue
if (deltaValue > 0) {
buyDeltaValueDict[istock] = deltaValue
}
else if (deltaValue < 0) {
sellDeltaValueDict[istock] = deltaValue
}
}
if (count(sellDeltaValueDict) != 0) {
sortedSellDeltaValueDict = sortDict(sellDeltaValueDict, true)
for (istock in sortedSellDeltaValueDict.keys()) {
executeSellOrders(
context, msg, sortedSellDeltaValueDict, istock)
}
}
if (count(buyDeltaValueDict) != 0) {
sortedBuyDeltaValueDict = sortDict(buyDeltaValueDict, false)
for (istock in sortedBuyDeltaValueDict.keys()) {
executeBuyOrders(
context, msg, sortedBuyDeltaValueDict, istock)
}
}
}
}
当前实现需要注意以下账户影响:
-
权重上限和下限在归一化之前应用,归一化后最终权重可能再次超过 maxPosition。
-
posList只包含当期信号选中的股票,未入选的旧持仓不会参与差额计算。 -
posList为空时不提交任何订单,因此不会把组合调整为空仓。 -
订单提交后没有检查实际成交,也没有在下一分钟追踪未成交差额或撤销订单。
-
买卖条件没有用括号明确
or与and tradeFlag的组合范围,维护时应把交易状态约束写得更明确。
2.3 设置回测参数
本策略使用分钟行情、字典形式的回调消息以及委托价撮合。脚本将 createBacktester 的第四个参数设为 false,以非 JIT 模式完成回测。回测配置结束日晚于数据结束日,实际可处理行情只到 CSV 的最后一个交易日。
config = {
startDate: 2024.01.01,
endDate: 2024.12.31,
strategyGroup: "stock",
frequency: 0,
cash: 10000000.,
commission: 0.0005,
tax: 0.0,
dataType: 3,
matchingMode: 3,
msgAsTable: false,
context: {
activeTime: 14:30m,
riskDict: {},
commission: 0.0005,
maxPosition: 0.1,
minPosition: 0.001
}
}
| 参数 | 类型 | 说明 |
|---|---|---|
| startDate | DATE | 回测开始日期;取值为 2024.01.01 |
| endDate | DATE | 回测结束日期;取值为 2024.12.31 |
| strategyGroup | STRING | 回测模式;取值为 "stock" |
| frequency | INT | 取值为 0,表示不从逐笔行情合成固定频率快照 |
| cash | DOUBLE | 初始资金;取值为 10000000.,表示 1,000 万元 |
| commission | DOUBLE | 佣金率;取值为 0.0005 |
| tax | DOUBLE | 印花税;取值为 0.0,表示不计印花税 |
| dataType | INT | 行情类型;取值为 3,表示分钟行情 |
| matchingMode | INT | 订单撮合模式;取值为 3,表示按委托价格成交 |
| msgAsTable | BOOL | 取值为 false,表示回调行情采用字典形式 |
| activeTime | MINUTE | 每日执行选股与调仓的时刻;取值为 14:30m |
| riskDict | DICTIONARY | 保存每只股票的风控状态,0 为正常、1 为风控 |
| maxPosition | DOUBLE | 归一化前的单股权重上限;取值为 0.1 |
| minPosition | DOUBLE | 归一化前的单股权重下限;取值为 0.001 |
commission 同时写入引擎配置和策略上下文:前者供回测引擎计费,后者用于买入前的现金检查,两处必须保持一致。
2.4 创建回测引擎
本策列注册初始化、盘前、分钟行情、逐笔行情、订单、成交、盘后和结束回调。beforeTrading 与 finalize 只输出日志。
try { loadPlugin("Backtest") } catch(ex) { print(ex) }
try { loadPlugin("MatchingEngineSimulator") } catch(ex) { print(ex) }
go
strategyName = "StockPortfolioBacktest"
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["beforeTrading"] = beforeTrading
callbacks["onBar"] = onBar
callbacks["onTick"] = onTick
callbacks["onOrder"] = onOrder
callbacks["onTrade"] = onTrade
callbacks["afterTrading"] = afterTrading
callbacks["finalize"] = finalize
try { Backtest::dropBacktestEngine(strategyName) } catch(ex) { print ex }
engine = Backtest::createBacktester(
strategyName, config, callbacks, false)
2.5 执行回测
运行时先把 CSV 存放在策略脚本配置的 DolphinDB Server 路径,再直接执行 stock_portfolio_backtest.dos。
Backtest::appendQuotationMsg(engine, messageTable)
Backtest::appendEndMarker(engine)
最后通过 appendEndMarker 发送结束标记,并保留当前引擎实例,以便在下一环节提取回测指标与交易明细。
2.6 获取回测结果
脚本分别读取状态为 1 和 -1 的交易详情,并取得每日账户权益与收益概述。状态行不等同于独立成交笔数;分析成交时还应结合返回表中的成交数量和订单状态变化。
successfulOrders = select *
from Backtest::getTradeDetails(engine)
where orderStatus == 1
rejectedOrders = select *
from Backtest::getTradeDetails(engine)
where orderStatus == -1
dailyTotalPortfolios = Backtest::getDailyTotalPortfolios(engine)
returnSummary = Backtest::getReturnSummary(long(engine))
-
Backtest::getTradeDetails用于核对订单和成交状态 -
Backtest::getAvailableCash返回可用资金 -
Backtest::getDailyPosition返回每日持仓 -
Backtest::getDailyTotalPortfolios返回每日账户权益 -
Backtest::getReturnSummary返回收益概述
3. 回测结果分析
本策略已在 DolphinDB Server 3.00.6.1 上完成 CSV 导入和非 JIT 回测。
| 项目 | 股票组合策略 |
|---|---|
| 数据记录数 | 2,546,400 条 |
| 模拟标的数 | 10 个 |
| 实际数据时间 | 2020-07-03 09:31:00 至 2024-11-15 15:00:00 |
| 回测账户初始资金 | 10,000,000 元 |
| 每日权益记录 | 210 天 |
股票组合回测得到 210 条每日权益记录,期末权益为 9,483,290.65 元,期末净值为 0.948329。最低权益约为 3,144,960.80 元,最高权益约为 11,707,113.39 元。随机分钟价格在相邻时点之间没有真实市场的连续性,导致年化波动率和最大回撤异常高;这些数值反映的是本次随机样本与当前代码行为,不代表真实投资组合风险。
| 指标 | 实际结果 |
|---|---|
| 总收益率 | -5.167094% |
| 年化收益率 | -6.168022% |
| 年化波动率 | 460.686101% |
| 夏普比率 | -0.013389 |
| 最大回撤 | 73.136326% |
| 换手率 | 1.393446% |
| 日胜率 | 51.428571% |
| 期末累计费用 | 12,826.47 元 |
4. 总结
本策略通过预计算技术指标、固定时刻选股、波动率调权和目标市值差额完成组合调仓。策略已在明确的 DolphinDB Server 环境中以非 JIT 模式运行完成,并保存交易详情、每日持仓、每日权益和收益概述。结果证明数据和回测链路可以执行,但随机数据、短样本与现有策略实现限制使收益指标不具备投资解释意义;迁移到真实行情前仍需修正前述策略问题,并补充真实交易制度、成本和合约规则。
5. 附录
以下为本策略的完整策略脚本(包含策略函数、回测配置和引擎执行流程)以及配套的模拟数据文件:
