上升三角形形态策略

上升三角形形态策略以“阶段性低点抬高、向上突破前期高点、此前振幅足够”为入场线索,并在所持股票的价格跌破短中期趋势线时离场。本文使用 Backtest 完成股票池过滤、形态识别、开盘撮合和结果汇总,重点说明如何将形态识别思路转化为可复现、可核查的回测案例。

1. 策略介绍

上升三角形通常表现为:价格多次尝试突破相对稳定的压力位,同时低点逐步上移。本案例将上述形态转化为三个可计算的条件,并从符合条件的模拟股票中选择价格较高的标的买入。卖出规则则使用前一交易日收盘价与 20 日均线的关系,避免当日收盘信息参与当日开盘决策。

1.1 核心因子

本案例的因子是由历史 OHLC 日线预先计算的形态与趋势特征。策略在 T 日开盘时作出交易判断,所用特征均来自 T-1 或更早时点的数据,不使用未来数据。

表 1. 表 1-1 核心因子
特征 含义 本例使用方式
20日振幅 过去一段时间的高低价格空间 要求 T-1 的 20 日最高价大于 20 日最低价的 1.05 倍,排除波动不足的标的
10日低点抬高 短期支撑逐步上移 要求 T-1 的 10 日最低价高于 T-2 的 10 日最低价
20日高点突破 价格突破较长期阻力 要求 T-1 收盘价高于截至 T-2 的 20 日最高价
20日均线 中短期趋势基准 持仓股票在 T-1 收盘价低于 T-1 的 20 日均线时,于 T 日开盘卖出

前三项条件同时满足时,即形成上升三角形的买入信号。这三个条件只是对形态的量化近似:20 日高点不必严格相等,10 日低点抬高也不等同于图形上的每一个回撤低点。因此,这组条件适合用于规则化筛选,但不应被视为经典形态的唯一数学定义。

1.2 交易标的

本例使用 50 个模拟日线数据,标的代码后缀字符串为 .SH,例如 000027.SH。本例还构建了一个模拟的 000300.SH 成分股快照,将这 50 只模拟证券全部视为指数成员;ST 历史表为空,模拟上市日期均早于回测开始日一年。因此,名称中的“沪深 300”只说明了股票池过滤流程,不代表任何真实指数成分或真实证券。

回测采用普通股票现金账户,只做多,不做空、融资或借券。主板和创业板买入数量向下取整为 100 股整数倍;对科创板标的保留“满 200 股后可逐股申报”的数量规则。日线订单按开盘价撮合,设置订单簿撮合比例为 0.2,故委托可能部分成交或未成交。

接入真实数据时,应使用带历史时点的指数成分、ST 状态、上市日期、停复牌状态、复权因子与涨跌停价格。

1.3 交易规则

一次日线回调按下列顺序处理:

  1. 先检查已有持仓:若前一日复权收盘价低于前一日 20 日均线,并且当日有成交量、开盘未跌停,则提交卖单。

  2. 从预计算的模拟成分股池中取得当日候选标的,剔除无成交量、开盘涨停或已有持仓的股票,并排除历史窗口尚未形成的记录。

  3. 对候选股票同时应用“20 日振幅超过 5%”、“10 日低点抬高”、“收盘突破此前 20 日高点”三个条件。

  4. 按前一日复权收盘价降序排列,最多选择 5 只;若实际只出现 N 只候选,则按 1/N 等权分配当前账户权益,不会用不合格股票补足 5 只。

  5. 使用 T 日开盘价、佣金和可用现金计算买入股数,向下调整到交易单位;卖出先于买入,以释放资金和持仓额度。

  6. 订单只在本次回调提交,实际成交量由撮合引擎和订单簿参与比例决定。

2. 策略回测

以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

下载附录中的 ascendingTriangle.csv 文件,作为回测的案例数据。回测账户权益记录覆盖 242 个交易日,起始日为2024-01-02,结束日为2024-12-31。

策略初始化中,需要通过构建每日的成分股股票池来传参,因此额外生成成分股数据。

表 2. 表 2-1 数据字段
数据类别 关键字段 用途
模拟行情 symbol、tradeTime、open、high、low、close、volume、amount 驱动日线回调与开盘撮合
模拟交易限制 upLimitPrice、downLimitPrice 过滤开盘涨停买入和跌停卖出情形
复权字段 adj_factor 计算形态窗口与 20 日均线;本例恒为 1,仅演示接口
形态特征 prev_close、prev_ma20、prev_high20、prev_low20、prev_low10、prev2_low10、prev2_high20 仅使用已完成交易日的数据识别形态与离场条件
模拟元数据 指数快照、ST 历史、上市日期 演示成分、ST 与上市满 9 0日的股票池过滤

为避免使用未来函数,先按交易时间对每只股票的数据排序,再将计算得到的特征序列后移一位或两位,形成相应的滞后特征。例如,T 日开盘使用的 prev_close 是 T-1 收盘价,prev2_high20 是截至 T-2 的 20 日最高价。真实研究还需处理复权、停牌、除权除息、涨跌停制度和数据缺失,而不是简单沿用模拟字段。

注意,这些模拟数值仅用于演示回测系统的执行流程,并非真实 A 股历史数据的测试结果,不能作为实盘参考。

2.2 编写策略逻辑

策略由交易单位处理、交易条件判断、初始化和日线回调构成。日线回调先处理卖出,后筛选并买入新标的,以便将卖出所得资金用于后续买入。

初始化时,将佣金设为 0.015%、卖出印花税设为 0.05%,并把调仓日历及按日期预计算的股票池放入 context。本例将所有交易日都作为检查日,但不等同于每天都交易:只有股票出现完整形态且通过各项限制时,策略才会提交该股票的买入委托。

def initialize(mutable context) {
    context["commission"] = 0.00015
    context["tax"] = 0.0005
    context["rebalanceDates"] = objByName("rebalanceDates_")
    context["poolByDate"] = objByName("poolByDate_")
}

买入筛选的核心逻辑可概括为:

  • 20 日最高价 > 20 日最低价 × 1.05

  • 且 T-1 的 10 日最低价 > T-2 的 10 日最低价

  • 且 T-1 收盘价 > 截至 T-2 的 20 日最高价

满足条件后,策略按 T-1 收盘价从高到低取前 5 只。实际入选少于 5 只时,资金仍按实际数量等权分配;若没有候选股票,策略不补仓。持仓卖出条件为 prev_close < prev_ma20,且必须通过当日成交量和跌停价格检查。

这种设计将“信号生成”和“执行可行性”分开:形态来自前一日及更早数据,开盘价、涨跌停和成交量决定当天能否提交或成交订单。实际交易系统还应进一步纳入滑点、冲击成本、单券容量和行业集中度约束。

onBar 回调的完整代码如下:

def onBar(mutable context, msg, indicator) {
    curBarTs = msg.values()[0]["tradeTime"]
    curDate = date(curBarTs)

    commission_ = context["commission"]
    tax_ = context["tax"]
    expectedCash = Backtest::getAvailableCash(context.engine)

    // 第一步:开盘处理卖出(先卖后买,释放资金与持仓)
    // 卖出条件:持仓股在前一日复权收盘价跌破前一日20日复权均线
    for (istock in msg.keys()) {
        bar = msg[istock]
        if (bar["volume"] <= 0) { continue } // 停牌跳过

        posInfo = Backtest::getPosition(context.engine, istock)
        if (size(posInfo) == 0) { continue }
        pos = posInfo["longPosition"]
        pos_sellable = max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"])
        if (pos <= 0 || pos_sellable <= 0) { continue }

        // 指标直接从行情 bar 中提取
        pClose = bar["prev_close"]
        pMa20 = bar["prev_ma20"]

        if (isValid(pClose) && isValid(pMa20) && pClose < pMa20) {
            if (!isSellableAt(bar["open"], bar)) { continue } // 开盘跌停跳过

            sellQty = normalizeSellQty(istock, pos, pos_sellable)
            if (sellQty > 0) {
                sellPrice = iif(isNull(bar["upLimitPrice"]), bar["open"], min(bar["open"], bar["upLimitPrice"]))
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, sellPrice, sellQty, 3), "sell")
                expectedCash += sellQty * sellPrice * (1.0 - commission_ - tax_)
            }
        }
    }

    // 第二步:买入条件筛选(直接通过预计算好的股票池做 O(1) 检索)
    if (!(curDate in context["rebalanceDates"])) { return }
    pool_today = context["poolByDate"][curDate]
    if (size(pool_today) == 0) { return }

    candidateSymbols = []$STRING
    candidatePrevClose = []$DOUBLE

    for (istock in msg.keys()) {
        if (!(istock in pool_today)) { continue }

        bar = msg[istock]
        if (bar["volume"] <= 0) { continue } // 停牌跳过
        if (!isBuyableAt(bar["open"], bar)) { continue } // 开盘涨停跳过

        // 仅对当前空仓股票进行买入筛选
        posInfo = Backtest::getPosition(context.engine, istock)
        pos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
        if (pos > 0) { continue }

        pClose = bar["prev_close"]
        pHigh20 = bar["prev_high20"]
        pLow20 = bar["prev_low20"]
        pLow10 = bar["prev_low10"]
        p2Low10 = bar["prev2_low10"]
        p2High20 = bar["prev2_high20"]

        if (!(isValid(pClose) && isValid(pHigh20) && isValid(pLow20) && isValid(pLow10) && isValid(p2Low10) && isValid(p2High20))) {
            continue
        }

        // 上升三角形买入三要素:
        // 1) 过去20日振幅充分:过去20日最高价 > 过去20日最低价 * 1.05
        // 2) 底部抬高:最新10日最低价 > 前一日的10日最低价
        // 3) 向上突破:前一日收盘价 > 截至前一日的20日最高价
        if ((pHigh20 > pLow20 * 1.05) && (pLow10 > p2Low10) && (pClose > p2High20)) {
            candidateSymbols.append!(istock)
            candidatePrevClose.append!(pClose)
        }
    }

    // 第三步:排序截断与开盘买入(按实际选中数量 nSelected 等权满仓均分)
    nCandidates = size(candidateSymbols)
    if (nCandidates == 0) { return }

    candTable = table(candidateSymbols as symbol, candidatePrevClose as prev_close)
    selectedTable = select top 5 * from candTable order by prev_close desc
    selectedStocks = exec symbol from selectedTable
    nSelected = size(selectedStocks)
    if (nSelected == 0) { return }

    // 真正等权满仓:按实际选中只数均分 (1 / nSelected)
    totalEquity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
    targetBudgetPerStock = totalEquity * (1.0 / nSelected)
    availCash = min(expectedCash, Backtest::getAvailableCash(context.engine))

    for (s in selectedStocks) {
        bar = msg[s]
        openPrice = bar["open"]
        if (openPrice <= 0) { continue }

        budget = min(targetBudgetPerStock, availCash)
        if (budget <= 0) { continue }

        buyQty = roundLotBuy(s, budget / (openPrice * (1.0 + commission_)))
        if (buyQty > 0) {
            Backtest::submitOrder(context.engine, (s, context.tradeTime, 5, openPrice, buyQty, 1), "buy")
            cost = buyQty * openPrice * (1.0 + commission_)
            availCash -= cost
            expectedCash -= cost
        }
    }
}

2.3 设置回测参数

表 3. 表 2-2 回测参数
参数 本例取值 说明
回测区间 2024-01-01 至 2024-12-31 使用上交所交易日历推进回测
初始资金 1,000,000 元 用于计算账户权益、净值与买入预算
手续费 0.00015 买卖均按成交金额计提佣金
卖出印花税 0.0005 卖出时计提
数据类型 4 股票日频行情并触发 onBar 回调
撮合模式 2 日频开盘价撮合
订单簿参与比例 0.2 限制委托可参与的成交量,可能出现部分成交或撤单状态
最低手续费 不启用 费用直接按比例计算
最大买入标的数 5 以实际入选数量等权,而非固定按 5 等分

佣金、印花税和部分成交共同影响最终账户权益。使用真实市场数据时,应按实际市场规则配置最低佣金、过户费、停牌、价格笼子和交易单位,并用合理的成交容量约束替代示例参数。

2.4 创建回测引擎

策略将 initialize 与 onBar 注册为回调,再以日频股票配置创建 Backtest 引擎。在创建前尝试清理同名引擎,避免上一次运行遗留的状态影响本次回测。

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

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

创建引擎后,行情按 tradeTime、symbol 排序送入。排序是日线事件回放的基础:同一交易日的股票构成一次回调,而策略只读取事先滞后的窗口特征,因此不会把同一天收盘信息用于同一天开盘交易。

2.5 执行回测

脚本调用 Backtest::appendQuotationMsg 回放全部模拟行情,再以 appendEndMarker 告知引擎数据结束。

allMarketData = select * from allMarketData order by tradeTime, symbol
Backtest::appendQuotationMsg(engine, allMarketData)
Backtest::appendEndMarker(engine)

2.6 获取回测结果

结果对象的使用方式如下:

// 获取核心回测结果
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
  • returnSummary:汇总区间收益、年化收益、波动率、夏普比率、最大回撤、换手率和胜率等引擎指标。

  • tradeDetails:保留委托状态、价格和数量,必须区分提交、部分成交、撤单与实际成交。

  • dailyPosition:逐日记录持仓数量、当日买卖量、均价和收盘价,可用于核对持仓变化。

  • totalPortfolios:逐日记录现金、市值、总权益、净值、损益和累计费用。

3. 回测结果分析

本次运行的回测结果指标如下:

表 4. 表 3-1 回测结果
指标 本次结果 解读
初始权益 1,000,000.00 元 回测资金起点
期末权益 3,476,709.51 元 年末无持仓,全部表现已体现在现金与已实现损益中
区间总收益 247.67% 单次模拟路径的区间资金变化
年化收益 266.04% 引擎按日频净值折算的年化指标
年化波动率 378.54% 极高,反映模拟价格路径与集中持仓造成的剧烈净值波动
最大回撤 84.37% 曾出现接近全部权益损失的深度回撤,风险不可忽略
夏普比率 0.7028 收益相对于波动的补偿很低,不能仅因总收益为正就判断策略优秀
日胜率 51.82% 策略在日度频率上的盈利概率
换手率 11.28% 反映策略调仓频率和资金周转情况

本次的高收益不能证明该形态策略在真实 A 股中有效,结果中 benchmarkReturn、alpha 和 beta 呈现的零值也不能解读为相对沪深 300 的超额表现。用于真实研究时,应将数据源替换为按历史可得时点整理的真实行情、复权、指数成分与交易状态数据,再进行多区间、样本外和成本敏感性检验。

4. 总结

本案例把上升三角形拆成可执行的三项历史特征:足够的 20 日振幅、10 日低点抬高和向上突破此前 20 日高点;再以跌破 20 日均线作为离场条件。通过模拟数据和 DolphinDB Backtest,可以完整走通从模拟日线、无未来特征预计算、股票池过滤、开盘撮合到订单与账户结果读取的流程。

当前脚本可作为形态策略的教学案例,也可用于接口验证。开展真实市场的策略研究前,应先确保数据可复现,并补全真实交易约束与基准;评价单次模拟收益时,还应结合回撤、波动、容量和样本外稳定性。