纳指ETF量化策略

2026-10-252

100万元上市以来的真实收益对比

同样100万元,保留底仓并加快买回后,期末资产比全部资金采用慢双均线多了134.35万元,达到911.50万元。 但一直持有是1014.62万元。新方案改善了结果,仍没有解决择时拖累收益的问题。

首图由聚宽已完成回测的账户数据重绘;下文保留真实平台指标截图。

1、收益与回撤对比

标的为国泰纳斯达克100ETF(513100),聚宽代码513100.XSHG。

回测从上市首日2013-05-15到2026-09-16,共3246个交易日,覆盖全部可交易历史。 基金成立至上市之间没有场内成交,不能把这段时间虚构为可买入;本轮全部实验统一截至2026年9月16日。

指标 本文策略 买入后一直持有
期末资产 / 万元 911.50 1014.62
累计收益 811.50% 914.62%
年化收益 18.55% 19.54%
最大回撤 27.39% 27.84%
平均实际仓位 91.42% 95.93%

与一直持有相比,策略少赚103.12万元,最大回撤减少0.45个百分点。要判断这种保护是否划算,应把这两项放在一起看。

两组起始资金均为100万元,最初买入预算均为90万元。“买入后一直持有”是从首个可成交日起按预算完成建仓,之后一直不动,不主动卖出、不补仓,现金分红留在账户。 余款保留现金,不追加本金。

本文策略将本金分为两个50万元子账户,各自使用45万元建仓预算、0.5%的分钟成交量限制;持有对照为单账户、1%的限制。下文另列同样分仓但取消择时的结果,分清分仓执行与择时的影响。

两组真实账户的回撤对比

最大回撤是账户从历史高点向后的最大跌幅;收益已计入平台记录的分红、份额调整和费用,年化收益沿用聚宽口径。

本文使用聚宽开发与回测,环境准备可参照基础篇。

2、量化策略的思路与实现

纳指的长期上涨,让错过反弹的代价不断累积。本次保留长期底仓,同时缩短择时部分重新入场的等待。

全部资金采用慢双均线时,重新确认上涨需要较长等待。本文将100万元等分为长期、择时两部分,择时部分用长期线决定退出,用短期修复决定买回。

同区间方案 累计收益 最大回撤
全部资金采用慢双均线 677.15% 26.57%
本文方案 811.50% 27.39%
买入后一直持有 914.62% 27.84%
环节 代码执行规则
资金分配 长期、择时各50万元;各自最初买入预算90%
退出 昨收低于MA250的97%,且20日涨跌幅低于−3%
恢复 昨收高于MA20的101%,且20日涨跌幅高于3%
跨境时点 只读取该场内ETF已经完成的A股交易日日线
底仓 长期账户不参与退出,不在两个账户间再平衡

这同时改变了参与程度和恢复速度。长期账户承受完整ETF涨跌,择时账户可在趋势修复早期回场。200日、300日窗口检验长期线附近的变化,额外延迟一天则检查优势是否过分依赖某个成交日。

人民币场内ETF价格还包含汇率、溢折价及跟踪误差。没有使用当天尚未发生的美股收盘,也没有用纳指指数收益冒充513100的可成交收益。

与“全部资金采用慢双均线”相比,收益提高的同时,最大回撤也从26.57%扩大到27.39%。保留更多上涨机会,也承受了更多下跌。

长期与择时两个真实子账户的资产变化

prepare_signal()只读取已经完成的日线;strategy_rule()判断继续持有或退出;execute_signal()提交今天的限价单;close_report()按真实成交核算现金、份额和费用。信号出现不代表一定成交,买回也不保证比卖出价更低。

重新入场只使用当时可交易账户总资产的90%;持有期间维持原份额,不每天补回90%仓位。

完整历史有成交订单92笔,累计佣金6762.32元。订单数量包含建仓和退出的分批成交,不等于买卖轮数。

平台真实成交记录节选

改变条件后,收益还能留下多少

检验 累计收益 最大回撤
本文策略 811.50% 27.39%
佣金与滑点翻倍 803.07% 27.39%
信号额外延迟1日 804.04% 27.39%
MA200 779.65% 27.38%
MA300 792.14% 27.38%
同样分仓,取消择时 914.62% 27.84%

成本、延迟、邻近参数的真实回测结果

四项成本、延迟与邻近参数检验中,0/4项超过原持有对照。主策略仍未超过持有,不能把终值提高等同于择时有效。

双倍成本把单边佣金从0.03%提高到0.06%、滑点从0.10%提高到0.20%,最低佣金仍为5元,限价偏移从0.2%扩大到0.4%。它与原成本持有账户比较,是更严格的压力检验;延迟试验额外跳过最近1个完整交易日的行情。邻近参数在提交回测前已固定,未改用其中收益最高的一组。

保持相同两账户和成交限制、取消择时后,期末为1014.62万元。本文策略比它少103.12万元,说明收益差异主要来自择时,不能归因于分仓成交方式。

连续账户每252交易日收益与持有的差异

滚动252交易日收益超过持有的窗口占9.08%;按不重叠的252交易日分段,胜出比例为8.33%。滚动窗口相互重叠,不是不相关的独立样本。

连续原账户分段 本文策略 一直持有
2013-05-15 至 2014-05-28 21.33% 21.33%
2014-05-29 至 2015-06-08 17.10% 17.10%
2015-06-09 至 2016-06-20 4.76% 6.12%
2016-06-21 至 2017-07-03 27.35% 27.82%
2017-07-04 至 2018-07-12 26.02% 26.37%
2018-07-13 至 2019-07-25 6.75% 10.86%
2019-07-26 至 2020-08-07 37.17% 38.05%
2020-08-10 至 2021-08-19 21.94% 22.31%
2021-08-20 至 2022-09-02 -11.12% -12.06%
2022-09-05 至 2023-09-15 29.27% 31.52%
2023-09-18 至 2024-10-09 23.83% 24.48%
2024-10-10 至 2025-10-22 28.34% 31.47%
2025-10-23 至 2026-09-16 17.04% 17.58%

以上均沿用原账户,不在分段起点清仓或重新投入100万元;末段不足252交易日的部分不纳入分段胜出比例。

当前证据还不足以称为“稳定高收益”。 本轮规则是在已经查看过的历史上修订的,此前也进行过多轮研究;全历史、分段、成本检验均属于回顾性验证。下一步应冻结规则,用尚未发生的行情做模拟跟踪,记录每次信号、未成交和实际成本,再判断能否投入实盘。

3、在聚宽复现回测

  1. 在“我的策略”中新建Python 3空白策略,粘贴下一节全部代码。
  2. 回测起点设为2013-05-15,终点设为2026-09-16,初始资金1000000元,运行频率选分钟。
  3. 检查代码中的ETF代码513100.XSHG,保持主参数和费用不变,运行到显示“回测完成”。
  4. 在“收益概述”核对收益、回撤,在“交易详情”核对成交,在“每日持仓&收益”核对资产。平台的“基准收益”是ETF参考线,不是本文独立运行的“买入后一直持有”账户。

聚宽原始回测结果:完整收益曲线及指标

上图为对应主策略的聚宽原始回测截图,包含回测区间、初始资金、完成状态、收益指标及完整收益曲线,未重写平台数字。图中基准线为对应ETF的页面参考线;正文“买入后一直持有”对照为另行回测的同一ETF账户,两者口径不同。

程序09:05读取昨日及更早行情,10:00挂限价单,10:05撤余单,15:10记账。上市起先按预算建仓,积累250个完整交易日后才启用择时,预热期的涨跌仍计入全历史。

费用为买卖各0.03%、每笔最低5元、基金印花税为0,单边滑点0.10%。买单按100份整手提交,部分成交可能不是整手;未完成买入按剩余原预算续单,卖出持续到可卖份额处理完毕。

复现持有对照时,复制一份相同代码,在CONFIG中只修改以下两项,回测资金、区间、频率保持一致:

'mode': 'hold',
'experiment': 'ETF17F_17_hold_compare',

要再检验“同样分仓、取消择时”,把mode设为split_hold并另换一个experiment名称,保留core: 0.5。

代码运行后会在研究文件中生成每日账户、订单和逐笔成交记录。复核总资产要用账面现金加持仓市值;可用现金可能不包含暂时冻结的资金。以上是历史撮合模拟,尚未接入券商实盘。

4、完整策略代码

复制以下全部代码即可运行。中文注释覆盖参数、信号、下单预算、成交限制和账户核对。

# -*- coding: utf-8 -*-
"""聚宽Python3完整策略。分钟回测,100万元;信号仅使用已完成日线。

长期底仓和择时仓使用真实子账户,资金总和始终为原100万元。
不借钱、不追加本金、不把固定资金比例误写成每日固定ETF仓位。
"""
from jqdata import *
from builtins import all, any, max, min
import csv
import io
import json
import math
import numpy as np
 
# 纳指ETF量化策略;国泰纳斯达克100ETF。
# mode: strategy为正文策略;hold为买入后一直持有;split_hold移除择时但保留分仓。
# core是初始本金分给长期账户的比例;后续不在账户之间转钱、不再平衡。
# delay_days只供延迟检验;reinvest_months为分红再投入的检查间隔。
CONFIG = {'security': '513100.XSHG',
 'listing': '2013-05-15',
 'mode': 'strategy',
 'commission': 0.0003,
 'slippage': 0.001,
 'limit_offset': 0.002,
 'delay_days': 0,
 'reinvest_months': 1,
 'experiment': 'ETF17F_17_main',
 'core': 0.5,
 'params': {'family': 'recovery', 'slow': 250, 'band': 0.03, 'recovery': 20, 'momentum': 20},
 'warmup': 250}
 
 
def strategy_rule(h, active, cfg):
    """入场与退出不对称;持有时查退出,空仓时查恢复,不用未来价格。"""
    p, c = cfg['params'], h.close.values
    if p['family'] == 'recovery':
        momentum = c[-1] / c[-p['momentum']-1] - 1
        leave = c[-1] < c[-p['slow']:].mean()*(1-p['band']) and momentum < -p['band']
        enter = c[-1] > c[-p['recovery']:].mean()*1.01 and momentum > p['band']
    elif p['family'] == 'fast_guard':
        leave = (c[-1] < c[-p['high_window']:].max()*(1-p['drop'])
                 and c[-1] < c[-p['slow']:].mean())
        enter = not leave and c[-1] > c[-p['recovery']:].mean()*1.01
    else:
        raise ValueError('未知择时规则')
    if active:
        return not leave, '触发风险退出' if leave else '继续持有择时仓'
    return bool(enter), '满足恢复入场' if enter else '等待价格恢复'
 
 
def initialize(context):
    """按配置划分原始本金;手续费、滑点和交易时间与对照相同。"""
    g.cfg = dict(CONFIG)
    assert abs(context.portfolio.starting_cash - 1000000) < .01
    assert g.cfg['mode'] in ('strategy', 'hold', 'split_hold')
    g.out = g.cfg['experiment']
    # hold只有一个不卖出的账户;split_hold保留相同分仓、移除择时作消融。
    core = 1. if g.cfg['mode'] == 'hold' else g.cfg['core']
    fractions = [('core', core), ('tactical', 1-core)]
    g.sleeves = []
    accounts = []
    for role, fraction in fractions:
        if fraction <= 0:
            continue
        idx = len(accounts)
        cash = 1000000*fraction
        accounts.append(SubPortfolioConfig(cash=cash, type='stock'))
        g.sleeves.append(dict(index=idx, role=role, initial_cash=cash,
            active=True, pending_exit=False, remaining=None, started=False, ever_bought=False,
            target=None, reason='', signal_date='', attempt=None, reserve=None))
    set_subportfolios(accounts)
    g.days = 0
    g.order_owner = {}
    g.reinvest_clock = 0
    set_benchmark(g.cfg['security'])  # 页面参考线仍不是本文独立持有账户。
    set_option('use_real_price', True)
    set_option('avoid_future_data', True)
    # 两个子账户各用0.5%,总和不超过1%,避免分账户放大可成交量。
    set_option('order_volume_ratio', .01/len(g.sleeves))
    set_order_cost(OrderCost(open_tax=0, close_tax=0,
        open_commission=g.cfg['commission'], close_commission=g.cfg['commission'],
        close_today_commission=0, min_commission=5), type='fund', ref=g.cfg['security'])
    set_slippage(PriceRelatedSlippage(2*g.cfg['slippage']), type='fund', ref=g.cfg['security'])
    run_daily(prepare_signal, time='09:05')
    run_daily(execute_signal, time='10:00')
    run_daily(cancel_remaining, time='10:05')
    run_daily(close_report, time='15:10')
    write_file(g.out+'_daily.csv', 'date,total_value,cash,shares,position_value,exposure,target,signal_date,reason\n', append=False)
    write_file(g.out+'_sleeves.csv', 'date,pindex,role,total_value,cash,shares,position_value,active,pending_exit,remaining,reserve\n', append=False)
    write_file(g.out+'_orders.csv', 'date,order_id,is_buy,requested,filled,price,commission,status,pindex\n', append=False)
    write_file(g.out+'_trades.csv', 'time,trade_id,order_id,amount,price\n', append=False)
    log.info('CONFIG '+json.dumps(g.cfg, ensure_ascii=True, sort_keys=True))
 
 
def position_of(context, s):
    """始终在对应子账户内读取持仓,底仓不会被择时卖单误卖。"""
    positions = context.subportfolios[s['index']].long_positions
    return positions[g.cfg['security']] if g.cfg['security'] in positions else None
 
 
def prepare_signal(context):
    """09:05决策;延迟检验多跳过最近1个已完成交易日。"""
    today = context.current_dt.date()
    prior = context.previous_date
    new_month = (today.year, today.month) != (prior.year, prior.month)
    if new_month and (today.month-1) % g.cfg['reinvest_months'] == 0:
        g.reinvest_clock = 1 + g.cfg['delay_days']
    reinvest_due = g.reinvest_clock == 1
    if g.reinvest_clock:
        g.reinvest_clock -= 1
    for s in g.sleeves:
        s.update(target=None, reason='', signal_date='')
        pos = position_of(context, s)
        held = int(pos.total_amount) if pos else 0
        passive = s['role'] == 'core' or g.cfg['mode'] == 'split_hold'
        if passive or g.cfg['params']['family'] == 'reinvest':
            s['target'], s['reason'] = True, '买入后一直持有'
            # 红利策略只投入已到账分红;首次建仓留下的现金储备固定保留。
            # 两个边界:首次建仓完成前不再投入;已有续单不重复发放预算。
            if (g.cfg['mode'] == 'strategy' and g.cfg['params']['family'] == 'reinvest'
                    and reinvest_due and s['started'] and s['remaining'] is None):
                sub = context.subportfolios[s['index']]
                s['remaining'] = max(0., sub.available_cash - s['reserve'])
                s['reason'] = '定期将已到账分红再投入'
            continue
        if s['pending_exit'] and held:
            s['target'], s['reason'] = False, '继续完成择时仓退出'
            continue
        s['pending_exit'] = False
        if g.days < g.cfg['warmup'] + g.cfg['delay_days']:
            s['target'], s['reason'] = True, '上市初期先建仓,历史齐备后择时'
            continue
        try:
            count = g.cfg['warmup'] + g.cfg['delay_days']
            h = attribute_history(g.cfg['security'], count, '1d',
                ['close', 'paused'], skip_paused=False, df=True, fq='pre')
            if h is None or len(h) != count or str(h.index[0].date()) < g.cfg['listing']:
                s['reason'] = '上市后历史不足,保留原状态'
                continue
            if h.index.has_duplicates or not h.index.is_monotonic_increasing or h.index[-1].date() != prior:
                raise ValueError('历史日期异常')
            if not np.isfinite(h.close.values).all() or (h.close <= 0).any():
                raise ValueError('历史价格异常')
            if h.paused.isnull().any() or h.paused.iloc[-1] != 0:
                raise ValueError('上日停牌或停牌字段异常')
            if g.cfg['delay_days']:
                h = h.iloc[:-g.cfg['delay_days']]
            active, reason = strategy_rule(h, s['active'], g.cfg)
            s.update(active=active, target=active, reason=reason, signal_date=str(h.index[-1].date()))
            if not active:
                s['remaining'] = None
                s['pending_exit'] = bool(held)
        except Exception as error:
            s['reason'] = 'DATA_ERROR '+str(error)
            log.warn(s['reason'])
 
 
def limit_price(price, high, low, buying, offset):
    """0.001元报价精度,涨停不追买、跌停不假定能卖出。"""
    if not all(math.isfinite(x) and x > 0 for x in (price, high, low)):
        return None
    if high <= low or not low <= price <= high:
        return None
    if buying:
        if price >= high-.001:
            return None
        return math.floor((min(price*(1+offset), high-.001)+1e-9)*1000)/1000
    if price <= low+.001:
        return None
    return math.ceil((max(price*(1-offset), low+.001)-1e-9)*1000)/1000
 
 
def affordable_amount(budget, price, commission):
    """买单预算含佣金,向下取100份整数;不挪用另一账户现金。"""
    if budget <= 5 or price <= 0:
        return 0
    return max(0, int((budget-5)/(price*(1+commission))/100)*100)
 
 
def execute_signal(context):
    """每个子账户每天最多一个限价单;未成交部分次日按剩余预算续单。"""
    today = context.current_dt.date()
    security = g.cfg['security']
    quote = get_current_data()[security]
    price = float(quote.last_price)
    if quote.paused or not math.isfinite(price) or price <= 0:
        return
    for s in g.sleeves:
        if s['target'] is None or s['attempt'] == today:
            continue
        sub = context.subportfolios[s['index']]
        pos = position_of(context, s)
        held = int(pos.total_amount) if pos else 0
        buying = s['target']
        if buying and held and s['remaining'] is None:
            continue
        if not buying and not held:
            continue
        lp = limit_price(price, float(quote.high_limit), float(quote.low_limit), buying, g.cfg['limit_offset'])
        if lp is None:
            continue
        if buying:
            if s['remaining'] is None:
                # 每次重新入场只使用本子账户资产的90%;原始建仓按原始本金。
                s['remaining'] = (sub.total_value if s['ever_bought'] else s['initial_cash'])*.9
            budget = min(s['remaining'], sub.available_cash)
            amount = affordable_amount(budget, lp, g.cfg['commission'])
            if amount == 0:
                s['remaining'] = None
                if not s['started']:
                    s['started'] = True
                    # 锁定首次建仓后剩余现金(含整手零头),今后只动增量分红。
                    s['reserve'] = float(sub.total_value - sub.positions_value)
        else:
            amount = -min(held, int(pos.closeable_amount))
        if not amount:
            continue
        s['attempt'] = today
        result = order(security, amount, style=LimitOrderStyle(lp), pindex=s['index'])
        if result is not None:
            g.order_owner[str(result.order_id)] = s['index']
 
 
def cancel_remaining(context):
    """撤掉本策略余单,保留实际成交;清仓指令持续到该子账户卖完。"""
    for o in get_open_orders().values():
        if o.security == g.cfg['security']:
            cancel_order(o)
 
 
def append_csv(suffix, values):
    buffer = io.StringIO()
    csv.writer(buffer).writerow(values)
    write_file(g.out+suffix, buffer.getvalue(), append=True)
 
 
def close_report(context):
    """逐笔成交核算预算,并保存各子账户,用于核查底仓、分红与资金守恒。"""
    cancel_remaining(context)
    fills = {}
    for t in get_trades().values():
        if t.security == g.cfg['security']:
            key = str(t.order_id)
            amount, turnover = fills.get(key, (0, 0.))
            fills[key] = amount+t.amount, turnover+t.amount*t.price
            append_csv('_trades.csv', [str(t.time), str(t.trade_id), key, t.amount, t.price])
    for o in get_orders().values():
        if o.security != g.cfg['security']:
            continue
        key = str(o.order_id)
        idx = g.order_owner[key]
        s = g.sleeves[idx]
        amount, turnover = fills.get(key, (0, 0.))
        assert amount == o.filled, '订单与逐笔成交量不一致'
        append_csv('_orders.csv', [str(context.current_dt.date()), key, bool(o.is_buy),
            o.amount, o.filled, turnover/amount if amount else 0., o.commission, str(o.status), idx])
        if o.is_buy and o.filled and s['remaining'] is not None:
            s['ever_bought'] = True
            s['remaining'] = max(0., s['remaining']-turnover-o.commission)
    total = cash = value = shares = 0.
    for s in g.sleeves:
        sub = context.subportfolios[s['index']]
        pos = position_of(context, s)
        qty = int(pos.total_amount) if pos else 0
        pv = float(sub.positions_value)
        total += sub.total_value
        cash += sub.available_cash
        shares += qty
        value += pv
        append_csv('_sleeves.csv', [str(context.current_dt.date()), s['index'], s['role'],
            sub.total_value, sub.available_cash, qty, pv, s['active'], s['pending_exit'],
            s['remaining'], s['reserve']])
    assert abs(total-context.portfolio.total_value) < .05, '子账户资金合计异常'
    record(account_nav=total/1000000, exposure=value/total)
    sources = [s['signal_date'] for s in g.sleeves if s['signal_date']]
    append_csv('_daily.csv', [str(context.current_dt.date()), total, cash, shares, value,
        value/total, '', max(sources) if sources else '', '|'.join(s['reason'] for s in g.sleeves)])
    g.days += 1

纳指这次改动减少了空仓的机会成本,但与一直持有相比,少赚的金额仍大,回撤改善却很有限。按提高收益并兼顾稳定性的目标,这套择时不宜替代长期持有。后续应固定规则跟踪新增行情,先证明退出有用,再讨论增加策略复杂度。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -