北向资金加仓策略

本案例实现北向资金加仓选股策略。策略先从沪深 300 成分股中筛选出近 5 个交易日北向持股比例增量为正的股票,再结合北向资金增量(权重 0.4)、市盈率(权重 0.3,低估值优先)和 ROE(权重 0.3,高质量优先)计算综合得分。选择综合得分排名前 10 的股票进行等权配置,每 20 个交易日调仓一次。信号在当日收盘后确定,并于下一交易日开盘按目标持仓执行调仓,以避免使用未来信息。

案例通过 DolphinDB Backtest 引擎完成股票池过滤、因子排名、目标仓位计算、交易约束处理和结果读取。随案例提供的数据为模拟数据;使用真实行情和北向持股数据时,只需替换数据准备部分,策略逻辑、回测流程和结果输出结构可以继续使用。

1. 策略介绍

北向资金通常指通过沪深港通流入 A 股市场的资金。北向持股比例的持续上升,可以作为观察境外资金配置变化的一种信号。这里的选股指标为 north_inc_5d,定义为当日北向持股比例与 5 个交易日前该比例的差:

north_inc_5d = north_hold_ratio - move(north_hold_ratio, 5)

每个调仓日,策略按以下顺序处理:

  1. 根据调仓日可获得的沪深 300 历史成分股快照取得股票池。

  2. 剔除 ST、上市不足 90 天和停牌股票。

  3. 仅保留 north_inc_5d > 0,且北向增量、PE、ROE 三个因子均有效的股票。

  4. 将通过筛选的股票,按北向资金增量(权重0.4)、PE(权重0.3)和 ROE(权重0.3)计算综合得分,按综合得分降序排序,最多选取前 10 只。

  5. 对实际入选的 N 只股票分配 1/N 权重;未满 10 只时不使用其他股票补足。

  6. 信号日用收盘价和账户权益锁定目标股数;下一交易日按开盘价先卖出、再买入。

调仓周期按有效日线回调次数计数。dayCount 从 -1 开始,因此第 1、21、41… 个有效交易日产生新的选股信号。策略不会在期末强制平仓,未卖出的持仓按最后一个交易日的收盘价计入组合权益。

2. 策略回测

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

2.1 准备数据

策略通过 loadText 函数,将本地 CSV 文件中的基础行情与因子数据载入内存。数据表包含量价、涨跌停价,以及预先计算的三类选股因子:北向持股比例的 5 日增量、市盈率和净资产收益率。

读入宽表后,代码自动提取唯一的证券代码生成初始股票池,并基于该股票池在内存中构造了三张配套的辅助数据表以支持选股过滤逻辑:

  1. 历史指数成分快照:将股票池内的代码与行情起始日期关联,生成统一的指数成分快照表。

  2. ST 历史表:初始化包含证券代码、起止时间等标准字段的数据表,供状态过滤逻辑调用。

  3. 上市时间信息表:为股票池中的所有股票统一配置基准上市日期(2010.01.01),以支持策略中关于上市满 90 天的条件筛选逻辑

data = loadText("/.../northboundNetBuyBarData.csv")
data.replaceColumn!("volume", long(data.volume))
//1. 股票池
stockPool = exec distinct symbol from data
// 2. 指数成分快照
n_ = size(stockPool)
snapDate_ = min(date(data.tradeTime))
tpIdxSnaps_ = table(take("000300.SH", n_) as index_code,
    take(snapDate_, n_) as snap_date,
    stockPool as con_code)

// 3. ST 历史:空表,表示没有 ST 股票
tpStHist_ = table(1:0, `code`start_date`period_end, [STRING, DATE, DATE])

// 4. 上市信息:所有股票上市日期统一设为 2010.01.01,确保上市满 90 天
tpListInfo_ = table(stockPool as code, take(2010.01.01, n_) as list_date)

在接入其他业务数据时,用户只需确保传入的数据表保留了相同的核心字段名称,无需修改前置的策略回调逻辑即可直接复用。准备就绪的全量数据将通过 Backtest::appendQuotationMsg 一次性推入引擎,并由 Backtest::appendEndMarker 标记行情回放结束。

2.2 编写策略逻辑

策略中的 roundLot 和 normalizeSellQty 分别处理买入数量和卖出数量。普通股票以 100 股为单位;科创板、北交所、可转债和 ETF/LOF 保留相应的数量规则。

initialize 订阅预计算的 north_inc_5d 指标,并保存手续费、印花税、股票池和调仓状态。onBar 分两个阶段处理:先按上一个信号日锁定的目标数量执行调仓,再根据本交易日的因子生成下一次调仓计划。

调仓执行的关键逻辑如下:

if (context["pendingRebalance"] && curDate != context["rebalanceDate"]) {
    tgtQtys_ = context["pendingTargetQtys"]
    expectedCash_ = Backtest::getAvailableCash(context.engine)

    // 第一轮:卖出未入选股票或超配部分。
    for (istock in msg.keys()) {
        bar = msg[istock]
        posInfo = Backtest::getPosition(context.engine, istock)
        // 按 T+1 可卖数量限制卖出,并检查委托价是否位于涨跌停区间。
        ...
    }

    // 第二轮:按得分排名顺序买入新入选或仓位不足的股票。
    for (istock in context["selectedStocks"]) {
        if (!(istock in msg)) { continue }
        bar = msg[istock]
        if (!isOrderPriceValid(bar["open"], bar)) { continue }
        // 用预计剩余现金和引擎可用现金限制买入数量。
        ...
    }
    context["pendingRebalance"] = false
}

信号生成时,先根据历史成分股、ST 与上市日期筛选可交易池,提取通过硬门槛(如 north_inc_5d > 0)的三类有效因子,再进行横截面加权打分并计算目标股数:

// 横截面打分:北向增量(0.4)、PE低估值(0.3)、ROE高质量(0.3),取得分最高的前10只
selectedStocks_ = []$STRING
if (size(symbols_) > 0) {
    factorT = table(symbols_ as symbol, northInc_ as north_inc, pe_ as pe, roe_ as roe)
    factorT = select symbol,
        (0.4 * (1.0 - rank(north_inc, false, percent=true))
            + 0.3 * (1.0 - rank(pe, true, percent=true))
            + 0.3 * (1.0 - rank(roe, false, percent=true))) as compositeScore
        from factorT
    scoreT = select symbol, compositeScore from factorT order by compositeScore desc, symbol asc limit 10
    selectedStocks_ = exec symbol from scoreT
}
context["selectedStocks"] = selectedStocks_

// 信号日收盘:按等权计算目标股数
if (size(selectedStocks_) > 0) {
    signalEquity_ = Backtest::getTotalPortfolios(context.engine).totalEquity.last()
    targetWeight = 1.0 / double(size(selectedStocks_))
    tgtQtys_ = dict(STRING, LONG)
    for (s_ in selectedStocks_) {
        tgtQtys_[s_] = roundLot(s_, signalEquity_ * targetWeight / (msg[s_]["close"] * (1 + context["commission"])))
    }
    context["pendingTargetQtys"] = tgtQtys_
    // ...
}

目标数量在信号日锁定,因此次日开盘跳空不会改变计划股数。实际成交仍会受可用现金、涨跌停、停牌、T+1 可卖数量和整手规则约束。若卖出委托未成交,计算买入可用资金时不计入对应的预计卖出收入。

2.3 设置回测参数

回测参数与交易成本如下:

config = {
    startDate: 2024.01.01,
    endDate: 2024.12.31,
    strategyGroup: "stock",
    cash: 1000000.0,
    commission: 0.00015,
    tax: 0.0005,
    dataType: 4,
    outputOrderInfo: true,
    matchingMode: 2,
    orderBookMatchingRatio: 0.2,
    enableMinimumPerTransactionFee: false
}

初始资金为 1,000,000 元。手续费率为成交额的 0.015%,卖出印花税率为 0.05%。outputOrderInfo = true 会保留订单状态信息,因此 tradeDetails 可包含未成交或部分成交状态,不能将其行数直接等同于成交笔数。

2.4 创建回测引擎

回调函数注册到 callbacks 字典,然后创建回测引擎。运行前会尝试删除同名遗留引擎;结束后再次删除,避免会话内重复执行时发生名称冲突。

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

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

2.5 执行回测

脚本按自然月循环准备行情和北向持股比例变化量,并把处理后的日线追加到引擎。示例版本从本地 CSV 文件加载数据;接入真实数据后,引擎输入流程保持不变:

Backtest::appendQuotationMsg(engine, data)
Backtest::appendEndMarker(engine);

末尾的 END 行用于通知引擎行情回放结束。它仅作为结束标记,不是可交易证券。

2.6 获取回测结果

回测结束后读取收益汇总、订单明细、每日持仓和每日组合权益。示例脚本使用模拟日线构建 000300.SH 基准净值;接入真实数据时,应替换为沪深 300 指数的真实历史行情。

tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)

3. 回测结果分析

本次使用当前脚本、以非 JIT 标志创建回测引擎,对 2024 年全年进行回测,共覆盖 242 个交易日。策略每 20 个有效交易日进行一次选股计算,全年共发生 13 次计算;因部分信号未形成可执行调仓,实际下单仅发生在 8 个交易日。每次最多选择 10 只股票,并按实际入选数量等权计算目标仓位。信号日按当日收盘价计算目标股数,下一交易日开盘时按先卖后买的顺序执行调仓。

首笔订单出现在 2024 年 1 月 3 日,最后一笔订单出现在 12 月 3 日。全期共有 15 个订单、33 条订单状态记录:已报 15 条、完全成交 12 条、部分成交 3 条、撤单 3 条。按成交状态统计,实际成交 15 条,其中买入 12 条、卖出 3 条,涉及 12 只股票。部分成交和撤单与撮合比例、可用资金、T+1 可卖数量、涨跌停及整手约束有关。

表 1. 表 3-1 回测结果
类别 指标 实际数值
收益表现 初始权益 1,000,000.00 元
收益表现 期末权益 / 净值 1,068,514.64 元 / 1.0685
收益表现 区间累计收益 +6.85%
收益表现 年化收益 +7.14%
基准 基准及相对表现 未配置基准;不可比较
风险表现 最大回撤 58.01%(2024-06-06 至 2024-12-18)
风险表现 年化波动率 / 夏普 342.23% / 0.0209
风险表现 日胜率 49.79%
盈亏与持仓 已实现 / 浮动盈亏 +222,127.92 元 / −153,613.28 元
盈亏与持仓 期末现金 / 股票市值 143,250.86 元 / 925,263.78 元
盈亏与持仓 股票仓位 86.59%
交易成本 累计费用 / 换手率 415.49 元 / 0.94%

收益方面,期末权益为 1,068,514.64 元,期末净值 1.0685,区间累计收益为 6.85%,年化收益为 7.14%。回测引擎未配置基准,因此不能给出同期基准收益、相对基准表现或“跑赢 / 跑输基准”的结论。组合的日胜率为 49.79%,但年化波动率高达 342.23%,夏普比率仅为 0.0209;正收益并不代表风险调整后的表现良好。

风险方面,日终净值在 2024 年 6 月 6 日达到 1.5690,随后于 12 月 18 日降至 0.6588,最大回撤为 58.01%。年末净值从低点反弹约 62.19%,但仍低于阶段高点。期末已实现盈亏为 222,127.92 元,浮动盈亏为 −153,613.28 元,合计盈利 68,514.64 元。

本次回测只能说明该输入数据和当前撮合参数下的策略流程可以完成执行。数据是否具备真实北向资金、指数成分变更、ST 状态和上市日期的统计特征,需要由数据来源另行验证。若接入真实数据,应先在配置中明确指定基准(如沪深 300),再检验累计收益、最大回撤、相对基准、行业集中度及不同市场阶段的稳定性。

4. 总结

本案例实现了“近 5 日北向持股比例增幅排名”的横截面选股流程:从沪深 300 历史成分股中剔除 ST 与上市不足 90 天的股票,选择北向持股比例增幅为正的前 10 只,信号日锁定目标数量,并在下一交易日开盘按资金、T+1、涨跌停和整手规则完成调仓。

本案例使用的模拟数据用于演示完整执行流程。用于真实投资研究时,应接入具有明确可用时点的沪深港通持股、指数成分、ST 区间、上市日期、公司行为和行情数据,并进一步评估交易成本、停牌、容量、行业暴露及样本外表现。