什么是前视偏误?

主题封面图
Photo by Emile Guillemot on Unsplash

本文重点概要

  • 文章难度:★★★☆☆
  • 使用布林通道执行自动化交易
  • 显示前视偏误如何影响交易结果

前言

「前视偏误」(Look-ahead bias)指的是在历史分析或决策过程中,不知情地使用未来或无法获得的资料或信息的错误。它出现在当资讯或数据在特定时间点上是未知或不可得知的情况下,被用于做出决策或进行评估,就好像这些资讯在当时已经知道了一样。
前视偏误可能会导致结果扭曲和误导,因为它违反了只使用分析时可用的资讯的原则。它可能出现在各个领域,包括金融、经济和数据分析,并可能影响投资策略、交易系统的回测、绩效评估和研究研究。
本次实作将展现金融领域中一个常见的前视偏误 ━ 在测试交易策略时使用历史价格或交易数据,但该数据包含未来价格变动的信息,而这些信息在当时是不可得知的。这可能导致人为膨胀的绩效结果和对策略盈利能力的不切实际的期望。

编辑环境与模组需求

本文使用 Mac 作业系统以及 Jupyter Notebook 作为编辑器。

import pandas as pd 
import re
import numpy as np 
import tejapi
from functools import reduce
import matplotlib.pyplot as plt
from collections import defaultdict, OrderedDict
from tqdm import trange, tqdm
import plotly.express as px
import plotly.graph_objects as go

tejapi.ApiConfig.api_key = "Your api key"
tejapi.ApiConfig.ignoretz = True

资料库使用

资料导入

资料期间从 2020–01–01 至 2023–06–30,以阳明海运 (2609) 作为实例,抓取未调整的收盘价、BB-Upper(20)、BB-Lower(20)资料,并以报酬指数(Y9997)作为大盘进行绩效比较。

stock_id = "2609"
gte, lte = '2020-01-01', '2023-06-30'
stock = tejapi.get('TWN/APRCD',
                   paginate = True,
                   coid = stock_id,
                   mdate = {'gte':gte, 'lte':lte},
                   opts = {
                       'columns':[ 'mdate', 'open_d', 'high_d', 'low_d', 'close_d', 'volume']
                   }
                  )
ta = tejapi.get('TWN/AVIEW1',
                paginate = True,
                coid = stock_id,
                mdate = {'gte':gte, 'lte':lte},
                opts = {
                    'columns':[  'mdate', 'bbu20', 'bbma20', 'bbl20']
                }
               )
market = tejapi.get('TWN/APRCD',
                   paginate = True,
                   coid = "Y9997",
                   mdate = {'gte':gte, 'lte':lte},
                   opts = {
                       'columns':[ 'mdate', 'close_d', 'volume']
                   }
                  )
data = stock.merge(ta, on = ['mdate'])
market.columns = ['mdate', 'close_m', 'volume_m']
data = data.set_index('mdate')

取得股价与技术指标资料后,与先前文章相同,使用 plotly.express 进行布林通道的视觉化。bbu20为20日布林通道上界、bbl20为通道的下界,而close_d为收盘价。

fig = px.line(data,   
            x=data.index, 
            y=["close_d","bbu20","bbl20"], 
            color_discrete_sequence = px.colors.qualitative.Vivid
            )
fig.show()
高端疫苗 (2609) 布林通道
阳明海运 (2609) 布林通道

本次实作将以阳明海运股价资料实作两种布林通道交易策略,比较两者差异

  1. 先前文章一致,当收盘价触碰到上界时,以隔日开盘价抛售持有部位。当收盘价触碰到下界时,视,以隔日开盘价买入一单位。当满足上述条件时,以及满足本金充足、已持有部位与当日收盘价低于上次买入讯号收盘价时,则继续加码一单位。
  2. 当收盘价触碰到上界时,以当日收盘价抛售持有部位。当收盘价触碰到下界时,视,以当日收盘价买入一单位。当满足上述条件时,以及满足本金充足、已持有部位与当日收盘价低于上次买入讯号收盘价时,则继续加码一单位。

由于两交易策略的差异仅仅在于交易时的单位价格不同,因此我们修改先前文章中的交易策略,将整个交易策略包装成一个函式 bollingeband_strategy,在程式码中增加一个 if 判断式,以函式的输入参数 — mode 控制条件,当 mode 等于 True 时,执行 策略 1,当mode 等于 False 时,执行 策略 2。

def bollingeband_strategy(data, principal, cash, position, order_unit, mode):
    trade_book = pd.DataFrame()
    
    for i in range(data.shape[0] -2):

        cu_time = data.index[i]
        cu_close = data.loc[cu_time, 'close_d']
        cu_bbl, cu_bbu = data.loc[cu_time, 'bbl20'], data.loc[cu_time, 'bbu20']
        
        if mode:
            n_time = data.index[i + 1]
            n_open = data['open_d'][i + 1]
        else:
            n_time = data.index[i]
            n_open = data['close_d'][i]


        if position == 0: #进场条件
            if cu_close <= cu_bbl and cash >= n_open*1000: 
                position += 1
                order_time = n_time
                order_price = n_open
                order_unit = 1
                friction_cost = (20 if order_price*1000*0.001425 < 20 else order_price*1000*0.001425)
                total_cost = -1 * order_price * 1000 - friction_cost
                cash += total_cost
                trade_book = pd.concat([trade_book,
                                       pd.DataFrame([stock_id, 'Buy', order_time, 0,  total_cost, order_unit, position, cash])],
                                       ignore_index = True, axis=1)


        elif position > 0:
            if cu_close >= cu_bbu: # 出场条件
                order_unit = position
                position = 0
                cover_time = n_time
                cover_price = n_open
                friction_cost = (20 if cover_price*order_unit*1000*0.001425 < 20 else cover_price*order_unit*1000*0.001425) + cover_price*order_unit*1000*0.003
                total_cost = cover_price*order_unit*1000-friction_cost
                cash += total_cost

                trade_book = pd.concat([trade_book,
                                       pd.DataFrame([stock_id, 'Sell', 0, cover_time,  total_cost, -1*order_unit, position, cash])],
                                       ignore_index = True, axis=1)

            elif cu_close <= cu_bbl and cu_close <= order_price and cash >= n_open*1000: #加码条件: 碰到下界,比过去买入价格贵
                order_unit = 1
                order_time = n_time
                order_price = n_open
                position += 1
                friction_cost = (20 if order_price*1000*0.001425 < 20 else order_price*1000*0.001425) 
                total_cost = -1 * order_price * 1000 - friction_cost
                cash += total_cost
                trade_book = pd.concat([trade_book,
                                       pd.DataFrame([stock_id, 'Buy', order_time, 0, total_cost, order_unit, position, cash])],
                                       ignore_index = True, axis=1)

    if position > 0: # 最后一天平仓
        order_unit = position
        position = 0
        cover_price = data['open_d'][-1]
        cover_time = data.index[-1]
        friction_cost = (20 if cover_price*order_unit*1000*0.001425 < 20 else cover_price*order_unit*1000*0.001425) + cover_price*order_unit*1000*0.003
        cash += cover_price*order_unit*1000-friction_cost
        trade_book = pd.concat([trade_book,
                               pd.DataFrame([stock_id, 'Sell',0,cover_time, cover_price*order_unit*1000-friction_cost, -1*order_unit, position, cash])],ignore_index=True, axis=1)


    trade_book = trade_book.T
    trade_book.columns = ['Coid', 'BuyOrSell', 'BuyTime', 'SellTime', 'CashFlow','TradeUnit', 'HoldingPosition', 'CashValue']
    
    return trade_book

接著,定义函式 simplify 简化产出报表资讯,以方便阅读。

def simplify(trade_book):
    trade_book_ = trade_book.copy()
    trade_book_['mdate'] = [trade_book.BuyTime[i] if trade_book.BuyTime[i] != 0 else trade_book.SellTime[i] for i in trade_book.index]
    trade_book_ = trade_book_.loc[:, ['BuyOrSell', 'CashFlow', 'TradeUnit', 'HoldingPosition', 'CashValue' ,'mdate']]
    return trade_book_

最后是绩效计算,由于 pandas 的套件版本更新,目前最新版本已不支援 append 这个功能,因此我们小幅度修改程式码使其能顺利运行,并将程式同样地打包成一个函式 back_test。

def back_test(principal, trade_book_, data, market):
    cash = principal
    data_ = data.copy()
    data_ = data_.merge(trade_book_, on = 'mdate', how = 'outer').set_index('mdate')
    data_ = data_.merge(market, on = 'mdate', how = 'inner').set_index('mdate')

    # fillna after merge
    data_['CashValue'].fillna(method = 'ffill', inplace=True)
    data_['CashValue'].fillna(cash, inplace = True)
    data_['TradeUnit'].fillna(0, inplace = True)
    data_['HoldingPosition'] = data_['TradeUnit'].cumsum()

    # Calc strategy value and return
    data_["StockValue"] = [data_['open_d'][i] * data_['HoldingPosition'][i] *1000 for i in range(len(data_.index))]
    data_['TotalValue'] = data_['CashValue'] + data_['StockValue']
    data_['DailyValueChange'] = np.log(data_['TotalValue']) - np.log(data_['TotalValue']).shift(1)
    data_['AccDailyReturn'] =  (data_['TotalValue']/cash - 1) *100

    # Calc BuyHold return
    data_['AccBHReturn'] = (data_['open_d']/data_['open_d'][0] -1) * 100

    # Calc market return
    data_['AccMarketReturn'] = (data_['close_m'] / data_['close_m'][0] - 1) *100

    # Calc numerical output
    overallreturn = round((data_['TotalValue'][-1] / cash - 1) *100, 4) # 总绩效
    num_buy, num_sell = len([i for i in data_.BuyOrSell if i == "Buy"]), len([i for i in data_.BuyOrSell if i == "Sell"]) # 买入次数与卖出次数
    num_trade = num_buy #交易次数

    avg_hold_period, avg_return = [], []
    tmp_period, tmp_return = [], []
    for i in range(len(trade_book_['mdate'])):
        if trade_book_['BuyOrSell'][i] == 'Buy':
            tmp_period.append(trade_book_["mdate"][i])
            tmp_return.append(trade_book_['CashFlow'][i])
        else:
            sell_date = trade_book_["mdate"][i]
            sell_price = trade_book_['CashFlow'][i] / len(tmp_return)
            avg_hold_period += [sell_date - j for j in tmp_period]
            avg_return += [ abs(sell_price/j) -1  for j in tmp_return]
            tmp_period, tmp_return = [], []

    avg_hold_period_, avg_return_ = np.mean(avg_hold_period), round(np.mean(avg_return) * 100,4) #平均持有期间,平均报酬
    max_win, max_loss = round(max(avg_return)*100, 4) , round(min(avg_return)*100, 4) # 最大获利报酬,最大损失报酬
    winning_rate = round(len([i for i in avg_return if i > 0]) / len(avg_return) *100, 4)#胜率
    min_cash = round(min(data_['CashValue']),4) #最小现金持有量

    print('总绩效:', overallreturn, '%')
    print('交易次数:', num_trade, '次')
    print('买入次数:', num_buy, '次')
    print('卖出次数:', num_sell, '次')
    print('平均交易报酬:', avg_return_, '%')
    print('平均持有期间:', avg_hold_period_ )
    print('胜率:', winning_rate, '%' )
    print('最大获利交易报酬:', max_win, '%')
    print('最大损失交易报酬:', max_loss, '%')
    print('最低现金持有量:', min_cash)

到此所有流程的程式码架构都已经撰写完毕,接下来就实际将资料放入模型,进行回测比较绩效差异。

以隔日开盘价进行交易

principal = 500000
cash = principal
position = 0
order_unit = 0

trade_book = bollingeband_strategy(data, principal, cash, position, order_unit, True)
trade_book_ = simplify(trade_book)
back_test(principal, trade_book_, data, market)
以隔日开盘价进行交易绩效计算结果
以隔日开盘价进行交易绩效计算结果

以当日收盘价进行交易

principal = 500000
cash = principal
position = 0
order_unit = 0

trade_book_cu_close = bollingeband_strategy(data, principal, cash, position, order_unit, False)
trade_book_cu_close_ = simplify(trade_book_cu_close)
back_test(principal, trade_book_cu_close_, data, market)
以当日收盘价进行交易绩效计算结果
以当日收盘价进行交易绩效计算结果

观察两种交易策略的结果,可以发现以当日收盘价进行交易的总绩效较优。股市新手在使用历史回测资料进行回测可能会错误以当日收盘价作为交易价格,而忽略了在实际市场进行操作时,不可能事先得知当日收盘价格是多少的,使用交易当下尚不可知的资料进行回测,即构成了所谓的「前视偏误」,导致回测结果产生差异,应当以隔日开盘价作为交易价格,才能反应最真实的交易情形。

结论

本次实作透过了简单的交易回测实作演示了在交易回测中暗藏的前视偏误,而这样的现象并不仅仅出现于交易回测,而是普遍的出现在金融领域中,为了避免前视偏误,重要的是确保历史分析或决策过程仅基于当时可用的资讯。这要求按照过去已知的方式使用历史数据,排除任何后续不可得知的信息。意识到前瞻性偏误并谨慎处理数据对于保持统计分析和决策过程的完整性和准确性至关重要。

温馨提醒,本次策略与标的仅供参考,不代表任何商品或投资上的建议。之后也会介绍使用TEJ资料库来建构各式指标,并回测指标绩效,所以欢迎对各种交易回测有兴趣的读者,选购TEJ E-Shop的相关方案,用高品质的资料库,建构出适合自己的交易策略。

完整程式码

延伸阅读

相关连结

返回总览页