股票投资组合交易策略

中低频策略通常基于日频或分钟级别的数据,通过分析市场趋势、估值指标、风险因子等,捕捉更长时间尺度上的投资机会。策略的核心在于设计一套科学有效的选股与仓位管理机制,从而实现收益最大化与风险控制。相比高频交易的短线博弈,中低频交易更关注策略在复杂市场条件下的适应性与风险控制,追求更高的资金利用效率和更稳定的回报。

本文介绍一个分钟频交易策略:使用均线、相对强弱指数(Relative Strength Index,RSI)和波动率构建股票组合,并在固定时刻根据目标市值调仓。

1. 策略介绍

近年来,金融市场的复杂性和不确定性显著增加,投资者面临着市场波动性增强、指数与个股联动性加剧以及多因子模型应用场景多样化的挑战。在此背景下,基于指数信号的股票投资组合交易策略应运而生,该策略通过结合指数信号的市场趋势分析和个股多因子筛选,旨在解决投资组合管理中仓位动态调整、个股筛选优化和风险控制的复杂难题。

1.1 核心因子

本策略同时使用趋势、动量和风险三类因子。均线交叉与 RSI 决定股票进入候选池还是剔除池,波动率不直接决定买卖方向,而是对入选股票的目标权重进行折减。

表 1. 表 1-1 股票投资组合策略核心因子
因子 计算口径 信号方向 用途
短期均线 最近 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 生成候选股票,再根据波动率调整权重:

  1. 短期均线上穿长期均线,或者 RSI 小于 70 时,将股票加入候选买入列表。

  2. 短期均线下穿长期均线,或者 RSI 大于 80 时,将股票加入剔除列表。

  3. 用候选买入列表减去剔除列表,得到当期目标股票池。

  4. 对目标股票先等权分配;波动率介于 0.005 和 0.01 之间时,把权重乘以 0.5;波动率大于 0.01 时,把权重乘以 0.1。

  5. 把单只股票权重限制在 0.001 至 0.1 之间,再将全部目标权重归一化为 1。

  6. 根据账户总权益计算目标市值,与现有持仓市值比较后先卖出、再买入。

策略将普通股票买入数量按 100 股的整数倍向下取整,将代码以 688 开头的股票按 200 股的整数倍向下取整。卖出数量按 100 股的整数倍计算;如果计划卖出量达到现有持仓,则直接使用全部持仓数量。

图 1. 图 1-1 股票中低频组合交易策略流程

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 和波动率的历史窗口。

表 2. 表 2-1 股票组合 CSV 字段结构
列名 类型 说明
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 固定长度数组向量。

表 3. 表 2-2 股票指标向量顺序
位置 指标 计算方式
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
    }
}
表 4. 表 2-5 股票组合回测参数
参数 类型 说明
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 回测。

表 5. 表 3-1 回测运行范围
项目 股票组合策略
数据记录数 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 元。随机分钟价格在相邻时点之间没有真实市场的连续性,导致年化波动率和最大回撤异常高;这些数值反映的是本次随机样本与当前代码行为,不代表真实投资组合风险。

表 6. 表 3-2 股票组合收益与风险指标
指标 实际结果
总收益率 -5.167094%
年化收益率 -6.168022%
年化波动率 460.686101%
夏普比率 -0.013389
最大回撤 73.136326%
换手率 1.393446%
日胜率 51.428571%
期末累计费用 12,826.47 元

4. 总结

本策略通过预计算技术指标、固定时刻选股、波动率调权和目标市值差额完成组合调仓。策略已在明确的 DolphinDB Server 环境中以非 JIT 模式运行完成,并保存交易详情、每日持仓、每日权益和收益概述。结果证明数据和回测链路可以执行,但随机数据、短样本与现有策略实现限制使收益指标不具备投资解释意义;迁移到真实行情前仍需修正前述策略问题,并补充真实交易制度、成本和合约规则。