QT3 用Backtrader实现AH溢价交易策略

0. 说明

下面对这份 AH 溢价套利策略的关键要素做一个量化化说明:

1.策略逻辑(AHPremiumArbitrage)

    • 思路:利用 A 股与 H 股在中国市场同一标的的价格差异(AH 溢价)进行套利。
    • 轮动频率:按月度轮动——每月第一个交易日全部调仓一次。
    • 选股规则
      1. 卖出上月持仓。
      2. 从所有可交易标的中,分别挑出 AH 溢价最高的 5 支 H 股,以及溢价最低的 5 支 A 股。
      3. 将当日可用现金等分为 10 份,每份买入一只选中的标的。

    2. 主要设置

    1. 初始资金:1 000 000 元
    2. 滑点:0.1%(由 cerebro.broker.set_slippage_perc(perc=0.001, …) 设置)
    3. 调仓时机:策略的 next() 方法中,通过比较当前日期的 (年, 月) 与上次执行的周期来决定是否调仓。
    4. 持仓跟踪:策略中维护 self.navself.nav_dates,实时记录策略净值与对应日期,便于后续绘图和绩效计算。

    3.缺失值/停牌处理

    .reindex(all_dates)                                            # 补全所有业务日
    df_feed[['open','high','low','close','premium']]\
        .ffill()                                                  # 向前填充
        .fillna(0)                                                # 首次上市前或全部缺失时置零
    df_feed['volume'] = df_feed['volume'].fillna(0)               # 停牌日或停市时成交量为0
    df_feed['listed'] = df_feed['close'].notna().astype(int)      # 上市/停牌标记
    
    • 策略执行时过滤:买入时只对 listed==1volume>0 的数据做选股,保证不会在未上市或停牌日下单。

    4.交易成本

    • A 股
      • 佣金:0.01%(单边)
      • 印花税:0.05%(仅卖方承担)
      • 通过自定义 CommInfoA.getcommission() 方法实现。
    • H 股
      • 佣金:0.03%(单边)
      • 印花税:0.05%(买卖双方都缴纳)
      • 通过自定义 CommInfoH.getcommission() 方法实现。
    • 滑点
      • 全局设置 0.1%,同时对开盘价、限价单、成交价都生效。

    5.绩效分析

    • 分析器
      • SharpeRatio(日频)
      • TimeReturn(年化回报)
      • DrawDown(最大回撤及最长回撤期)
    • 净值曲线
      • 在策略内 next() 中持续收集 broker.getvalue(),可用于绘制总资产随时间的走势曲线。
    • 年度回报和年末净值
      • TimeReturn 分析器读取各年度回报率,再结合 self.navself.nav_dates 获取各年最后一日的资产值。

    通过以上设置,这个月度轮动的 AH 溢价套利策略能够:

    • 自动规避停牌/未上市标的;
    • 量化考量交易成本与滑点;
    • 结合多种分析器对夏普率、年度收益、回撤等进行全方位绩效评估;
    • 输出详细的买卖信号与交易闭合日志,便于事后复盘和审计。

    1. 代码

    import pandas as pd
    import backtrader as bt
    from datetime import datetime
    import matplotlib.pyplot as plt
    
    # ——————————— 0. 读取 AH 数据 ———————————
    ahp = pd.read_feather(r"data/AH_premium.feather")
    
    # ——————————— 1. 预处理日期 ———————————
    ahp["trade_date"] = pd.to_datetime(
        ahp["trade_date"].astype(str), format="%Y%m%d"
    )
    ahp.sort_values("trade_date", inplace=True)
    earliest = ahp["trade_date"].min()
    latest   = ahp["trade_date"].max()
    all_dates = pd.date_range(start=earliest, end=latest, freq="B")
    
    # ——————————— 2. 自定义数据源 Feed ———————————
    class CustomPandasData(bt.feeds.PandasData):
        lines = ('listed', 'premium')
        params = (
            ('listed', -1),
            ('premium', -1),
        )
    
    # ——————————— 3. 佣金与印花税设置 ———————————
    class CommInfoA(bt.CommissionInfo):
        params = dict(
            commission=0.0001,
            stamp=0.0005,
            stocklike=True,
            commtype=bt.CommissionInfo.COMM_PERC
        )
        def getcommission(self, size, price, pseudoexec=False):
            cost = abs(size) * price * self.p.commission
            if size < 0:
                cost += abs(size) * price * self.p.stamp
            return cost
    
    class CommInfoH(bt.CommissionInfo):
        params = dict(
            commission=0.0003,
            stamp=0.0005,
            stocklike=True,
            commtype=bt.CommissionInfo.COMM_PERC
        )
        def getcommission(self, size, price, pseudoexec=False):
            cost = abs(size) * price * self.p.commission
            cost += abs(size) * price * self.p.stamp
            return cost
    
    # ——————————— 4. 策略定义:AH 溢价套利 ———————————
    class AHPremiumArbitrage(bt.Strategy):
        def __init__(self):
            self.last_period = (None, None)
            self.nav = []
            self.nav_dates = []
    
        def notify_order(self, order):
            if order.status in [order.Submitted, order.Accepted]:
                return
            if order.status == order.Completed:
                action = 'BUY' if order.isbuy() else 'SELL'
                print(f"{action} EXECUTED: {order.data._name}, Price: {order.executed.price:.2f}, Size: {order.executed.size}")
            else:
                print(f"Order {order.getordername()} on {order.data._name} Canceled/Margin/Rejected")
    
        def notify_trade(self, trade):
            if trade.isclosed:
                print(f"TRADE CLOSED: {trade.data._name}, Gross PnL: {trade.pnl:.2f}, Net PnL: {trade.pnlcomm:.2f}")
    
        def next(self):
            # 将 datetime 换为纯 date 对象以便后续匹配
            dt = self.datas[0].datetime.date(0)
            self.nav_dates.append(dt)
            self.nav.append(self.broker.getvalue())
            period = (dt.year, dt.month)
    
            if period != self.last_period:
                # 卖出所有持仓
                for d in self.datas:
                    pos = self.getposition(d).size
                    if pos:
                        print(f"SELL SIGNAL: {d._name} at Close {d.close[0]:.2f} on {dt}")
                        self.sell(data=d, size=pos, exectype=bt.Order.Market, coo=True)
                # 可交易标的筛选
                h_list, a_list = [], []
                for d in self.datas:
                    if d.listed[0] and d.volume[0] > 0:
                        if d._name.endswith('_H'):
                            h_list.append((d.premium[0], d))
                        else:
                            a_list.append((d.premium[0], d))
                # 选股
                h_sel = sorted(h_list, key=lambda x: x[0], reverse=True)[:5]
                a_sel = sorted(a_list, key=lambda x: x[0])[:5]
                # 等额买入,滑点已全局设置 0.1%
                cash = self.broker.getcash()
                alloc = cash / 10
                for _, d in h_sel + a_sel:
                    price = d.open[0]
                    size = int(alloc / price) if price > 0 else 0
                    if size:
                        print(f"BUY SIGNAL: {d._name} at Open {price:.2f} on {dt}")
                        self.buy(data=d, size=size, exectype=bt.Order.Market, coo=True)
                self.last_period = period
    
        def stop(self):
            # 同样用 date 对象记录最终净值
            dt = self.datas[0].datetime.date(0)
            self.nav_dates.append(dt)
            self.nav.append(self.broker.getvalue())
    
    if __name__ == '__main__':
        cerebro = bt.Cerebro(cheat_on_open=True)
        cerebro.broker.setcash(1_000_000.0)
        cerebro.broker.set_slippage_perc(perc=0.001, slip_open=True, slip_limit=True, slip_match=True)
    
        # 添加分析器
        cerebro.addanalyzer(bt.analyzers.SharpeRatio, timeframe=bt.TimeFrame.Days, _name='sharpe')
        cerebro.addanalyzer(bt.analyzers.TimeReturn, timeframe=bt.TimeFrame.Years, compression=1, _name='yearlyreturn')
        cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    
        # 数据加载
        def load_data(prefix, ts_col):
            for code in ahp[ts_col].unique():
                df = ahp[ahp[ts_col] == code].copy()
                df.set_index('trade_date', inplace=True)
                df_feed = df[[
                    f'open_adj_{prefix}', f'high_adj_{prefix}', f'low_adj_{prefix}',
                    f'close_adj_{prefix}', f'vol_{prefix}', 'AH_close_premium'
                ]].rename(columns={
                    f'open_adj_{prefix}':'open', f'high_adj_{prefix}':'high',
                    f'low_adj_{prefix}':'low', f'close_adj_{prefix}':'close',
                    f'vol_{prefix}':'volume', 'AH_close_premium':'premium'
                }).reindex(all_dates)
                df_feed['listed'] = df_feed['close'].notna().astype(int)
                df_feed[['open','high','low','close','premium']] = df_feed[['open','high','low','close','premium']].ffill().fillna(0)
                df_feed['volume'] = df_feed['volume'].fillna(0)
                name = df.get('name', pd.Series([code])).iat[0]
                datafeed = CustomPandasData(dataname=df_feed, name=f"{name}_{prefix}")
                cerebro.adddata(datafeed)
                # 佣金设置
                cerebro.broker.addcommissioninfo(CommInfoA() if prefix=='A' else CommInfoH())
    
        load_data('A', 'ts_code_A')
        load_data('H', 'ts_code_H')
    
        cerebro.addstrategy(AHPremiumArbitrage)
    
        print(f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}")
        strat = cerebro.run()[0]
        print(f"Final Portfolio Value:   {cerebro.broker.getvalue():.2f}")
    
        # 输出绩效
        sr = strat.analyzers.sharpe.get_analysis().get('sharperatio')
        print(f"Sharpe Ratio: {sr:.2f}" if sr else "Sharpe Ratio: N/A")
        yr = strat.analyzers.yearlyreturn.get_analysis()
        nav = strat.nav
        nav_dates = strat.nav_dates
        print("Annual Returns and Year-End Value:")
        for year_dt, ret in sorted(yr.items()):
            try:
                idx = nav_dates.index(year_dt)
                end_val = nav[idx]
            except ValueError:
                end_val = float('nan')
            print(f" {year_dt.year}: Return {ret*100:.2f}%, Year-End Value {end_val:.2f}")
        dd = strat.analyzers.drawdown.get_analysis().get('max', {})
        print(f"Max Drawdown: {dd.get('drawdown',0):.2f}%")
        print(f"Longest DD Duration: {dd.get('len',0)} bars")
    

    2. 运行结果

    能够成功打印历史交易行为和各种策略评估值

    ...
    SELL SIGNAL: 中国联通_A at Close 5.26 on 2025-05-01
    SELL SIGNAL: 中国重汽_A at Close 16.59 on 2025-05-01
    SELL SIGNAL: 招商银行_A at Close 40.74 on 2025-05-01
    SELL SIGNAL: 药明康德_A at Close 57.92 on 2025-05-01
    SELL SIGNAL: 美的集团_A at Close 70.23 on 2025-05-01
    SELL SIGNAL: 上海电气_H at Close 2.55 on 2025-05-01
    SELL SIGNAL: 浙江世宝_H at Close 3.29 on 2025-05-01
    SELL SIGNAL: 中国稀土_H at Close 0.39 on 2025-05-01
    SELL SIGNAL: 复旦张江_H at Close 2.43 on 2025-05-01
    SELL SIGNAL: 弘业期货_H at Close 2.34 on 2025-05-01
    SELL EXECUTED: 中国联通_A, Price: 5.27, Size: -46
    SELL EXECUTED: 中国重汽_A, Price: 17.39, Size: -12
    SELL EXECUTED: 招商银行_A, Price: 41.60, Size: -5
    SELL EXECUTED: 药明康德_A, Price: 59.44, Size: -3
    SELL EXECUTED: 美的集团_A, Price: 71.47, Size: -3
    SELL EXECUTED: 上海电气_H, Price: 2.55, Size: -97
    SELL EXECUTED: 浙江世宝_H, Price: 3.22, Size: -76
    SELL EXECUTED: 中国稀土_H, Price: 0.39, Size: -652
    SELL EXECUTED: 复旦张江_H, Price: 2.42, Size: -100
    SELL EXECUTED: 弘业期货_H, Price: 2.34, Size: -97
    TRADE CLOSED: 中国联通_A, Gross PnL: -10.05, Net PnL: -10.44
    TRADE CLOSED: 中国重汽_A, Gross PnL: -25.31, Net PnL: -25.66
    TRADE CLOSED: 招商银行_A, Gross PnL: -6.57, Net PnL: -6.91
    TRADE CLOSED: 药明康德_A, Gross PnL: -22.03, Net PnL: -22.33
    TRADE CLOSED: 美的集团_A, Gross PnL: -1.43, Net PnL: -1.78
    TRADE CLOSED: 上海电气_H, Gross PnL: -6.32, Net PnL: -6.72
    TRADE CLOSED: 浙江世宝_H, Gross PnL: -5.50, Net PnL: -5.90
    TRADE CLOSED: 中国稀土_H, Gross PnL: -6.52, Net PnL: -6.93
    TRADE CLOSED: 复旦张江_H, Gross PnL: -22.51, Net PnL: -22.91
    TRADE CLOSED: 弘业期货_H, Gross PnL: -30.55, Net PnL: -30.94
    Final Portfolio Value:   1532157.33
    Sharpe Ratio: 0.01
    Annual Returns and Year-End Value:
     2012: Return 2.54%, Year-End Value 1025428.06
     2013: Return -12.87%, Year-End Value 893414.49
     2014: Return -1.19%, Year-End Value 882795.35
     2015: Return 6.41%, Year-End Value 939403.78
     2016: Return 21.46%, Year-End Value 1140994.00
     2017: Return 2.04%, Year-End Value 1164264.34
     2018: Return -18.56%, Year-End Value 948194.14
     2019: Return 10.75%, Year-End Value 1050094.54
     2020: Return 22.96%, Year-End Value 1291215.56
     2021: Return 8.75%, Year-End Value 1404163.10
     2022: Return 11.04%, Year-End Value 1559246.09
     2023: Return -5.00%, Year-End Value 1481300.93
     2024: Return 1.89%, Year-End Value 1509248.71
     2025: Return 1.52%, Year-End Value 1532157.33
    Max Drawdown: 26.00%
    Longest DD Duration: 707 bars

    发表评论