Capítulo 4. Dominar el Backtesting Vectorizado

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

[Fueron tan tontos como para pensar que se puede mirar al pasado para predecir el futuro.1

El Economista

Desarrollar ideas e hipótesis para un programa de negociación algorítmica suele ser la parte más creativa y a veces incluso divertida de la fase de preparación. Probarlas a fondo suele ser la parte más técnica y que requiere más tiempo. Este capítulo trata del backtesting vectorizado de diferentes estrategias de negociación algorítmica. Abarca los siguientes tipos de estrategias (consulta también "Estrategias de negociación"):

Estrategias basadas en medias móviles simples (SMA)

La idea básica del uso de las SMA para generar señales de compra y venta tiene ya décadas de antigüedad. Las SMA son una herramienta fundamental en el llamado análisis técnico de los precios de las acciones. Se obtiene una señal, por ejemplo, cuando una SMA definida en una ventana temporal más corta -digamos 42 días- cruza una SMA definida en una ventana temporal más larga -digamos 252 días-.

Estrategias de impulso

Son estrategias que se basan en la hipótesis de que el rendimiento reciente persistirá durante algún tiempo adicional. Por ejemplo, se supone que una acción con tendencia a la baja lo hará durante más tiempo, por lo que hay que venderla en corto.

Estrategias de reversión de la media

El razonamiento que subyace a las estrategias de reversión a la media es que los precios de las acciones o de otros instrumentos financieros tienden a volver a algún nivel medio o a algún nivel de tendencia cuando se han desviado demasiado de dichos niveles.

El capítulo procede como sigue. "Hacer uso de la vectorización" presenta la vectorización como un enfoque técnico útil para formular y backtestar estrategias de negociación. " Estrategias basadas en Medias Móviles Simples" es el núcleo de este capítulo y trata en profundidad el backtesting vectorizado de estrategias basadas en SMA. " Estrategias basadas en el impulso" presenta y realiza pruebas retrospectivas de estrategias de negociación basadas en el denominado impulso de la serie temporal ("rendimiento reciente") de una acción. "Estrategias basadas en la reversión a la media " termina el capítulo con la cobertura de las estrategias de reversión a la media. Por último, " Espionaje de datos y sobre ajuste " trata de los peligros del espionaje de datos y el sobreajuste en el contexto del backtesting de estrategias de negociación algorítmica.

El principal objetivo de este capítulo es dominar el enfoque de la implementación vectorizada, que permiten paquetes como NumPy y pandas, como herramienta de backtesting eficaz y rápida. Para ello, los enfoques presentados hacen una serie de suposiciones simplificadoras para centrar mejor el debate en el tema principal de la vectorización.

El backtesting vectorizado debe considerarse en los siguientes casos:

Estrategias de negociación sencillas

El enfoque de backtesting vectorizado tiene claramente límites cuando se trata de modelar estrategias de negociación algorítmica. Sin embargo, muchas estrategias populares y sencillas pueden someterse a backtesting vectorizado.

Exploración interactiva de estrategias

El backtesting vectorizado permite una exploración ágil e interactiva de las estrategias de negociación y sus características. Por lo general, bastan unas pocas líneas de código para obtener los primeros resultados, y se pueden probar fácilmente distintas combinaciones de parámetros.

Visualización como objetivo principal

El enfoque se presta bastante bien para visualizaciones de los datos utilizados, estadísticas, señales y resultados de rendimiento. Unas pocas líneas de código Python suelen bastar para generar gráficos atractivos y perspicaces.

Programas completos de backtesting

El backtesting vectorizado es bastante rápido en general, ya que permite probar una gran variedad de combinaciones de parámetros en poco tiempo. Cuando la velocidad es clave, debe considerarse este enfoque.

Utilizar la vectorización

La vectorización, o programación de matrices, se refiere a un estilo de programación en el que las operaciones sobre escalares (es decir, números enteros o de coma flotante) se generalizan a vectores, matrices o incluso matrices multidimensionales. Considera un vector de enteros v = (1,2,3,4,5) T representado en Python como un objeto list v = [1, 2, 3, 4, 5] . Calcular el producto escalar de dicho vector y, digamos, el número 2 requiere en Python puro un bucle for o algo similar, como una comprensión de lista, que no es más que una sintaxis diferente para un bucle for:

In [1]: v = [1, 2, 3, 4, 5]

In [2]: sm = [2 * i for i in v]

In [3]: sm
Out[3]: [2, 4, 6, 8, 10]

En principio, Python permite multiplicar un objeto list por un número entero, pero el modelo de datos de Python devuelve otro objeto list que, en el caso del ejemplo, contiene dos veces los elementos del objeto original:

In [4]: 2 * v
Out[4]: [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]

Vectorización con NumPy

El paquete NumPy para cálculo numérico (véase la página principalNumPy ) introduce la vectorización en Python. La clase principal que proporciona NumPy es la clase ndarray, que significa matriz n-dimensional. Se puede crear una instancia de este objeto, por ejemplo, a partir del objeto list v . La multiplicación escalar, las transformaciones lineales y operaciones similares del álgebra lineal funcionan entoncescomo se desea:

In [5]: import numpy as np  1

In [6]: a = np.array(v)  2

In [7]: a  3
Out[7]: array([1, 2, 3, 4, 5])

In [8]: type(a)  4
Out[8]: numpy.ndarray

In [9]: 2 * a  5
Out[9]: array([ 2,  4,  6,  8, 10])

In [10]: 0.5 * a + 2  6
Out[10]: array([2.5, 3. , 3.5, 4. , 4.5])
1

Importa el paquete NumPy.

2

Instancia un objeto ndarray basado en el objeto list.

3

Imprime los datos almacenados como objeto ndarray.

4

Busca el tipo del objeto.

5

Consigue una multiplicación escalar de forma vectorizada.

6

Consigue una transformación lineal de forma vectorizada.

La transición de una matriz unidimensional (un vector) a una matriz bidimensional (una matriz) es natural. Lo mismo ocurre con las dimensiones superiores:

In [11]: a = np.arange(12).reshape((4, 3))  1

In [12]: a
Out[12]: array([[ 0,  1,  2],
                [ 3,  4,  5],
                [ 6,  7,  8],
                [ 9, 10, 11]])

In [13]: 2 * a
Out[13]: array([[ 0,  2,  4],
                [ 6,  8, 10],
                [12, 14, 16],
                [18, 20, 22]])

In [14]: a ** 2  2
Out[14]: array([[  0,   1,   4],
                [  9,  16,  25],
                [ 36,  49,  64],
                [ 81, 100, 121]])
1

Crea un objeto unidimensional ndarray y lo remodela a dos dimensiones.

2

Calcula el cuadrado de cada elemento del objeto de forma vectorizada.

Además, la clase ndarray proporciona ciertos métodos que permiten realizar operaciones vectorizadas. A menudo también tienen homólogos en forma de las llamadas funciones universales que proporciona NumPy:

In [15]: a.mean()  1
Out[15]: 5.5

In [16]: np.mean(a)  2
Out[16]: 5.5

In [17]: a.mean(axis=0)  3
Out[17]: array([4.5, 5.5, 6.5])

In [18]: np.mean(a, axis=1)  4
Out[18]: array([ 1.,  4.,  7., 10.])
1

Calcula la media de todos los elementos mediante una llamada a un método.

2

Calcula la media de todos los elementos mediante una función universal.

3

Calcula la media a lo largo del primer eje.

4

Calcula la media a lo largo del segundo eje.

Como ejemplo financiero, considera la función generate_sample_data() en "Scripts de Python" que utiliza una discretización de Euler para generar trayectorias de muestra para un movimiento browniano geométrico. La implementación hace uso de múltiples operaciones vectorizadas que se combinan en una sola línea de código.

Consulta el Apéndice A para obtener más detalles sobre la vectorización con NumPy. Consulta Hilpisch (2018) para ver multitud de aplicaciones de la vectorización en un contexto financiero.

El conjunto de instrucciones estándar y el modelo de datos de Python no suelen permitir operaciones numéricas vectorizadas. NumPy introduce potentes técnicas de vectorización basadas en la clase de matrices regulares ndarray que dan lugar a un código conciso y cercano a la notación matemática de, por ejemplo, álgebra lineal en lo que respecta a vectores y matrices.

Vectorización con pandas

El paquete pandas y la clase central DataFrame utilizan en gran medida NumPy y la clase ndarray. Por lo tanto, la mayoría de los principios de vectorización vistos en el contexto de NumPy se trasladan a pandas. La mejor forma de explicar la mecánica es de nuevo a partir de un ejemplo concreto. Para empezar, define primero un objeto bidimensional ndarray:

In [19]: a = np.arange(15).reshape(5, 3)

In [20]: a
Out[20]: array([[ 0,  1,  2],
                [ 3,  4,  5],
                [ 6,  7,  8],
                [ 9, 10, 11],
                [12, 13, 14]])

Para la creación de un objeto DataFrame, genera a continuación un objeto list con nombres de columnas y un objeto DatetimeIndex, ambos de tamaño adecuado dado el objeto ndarray:

In [21]: import pandas as pd  1

In [22]: columns = list('abc')  2

In [23]: columns
Out[23]: ['a', 'b', 'c']

In [24]: index = pd.date_range('2021-7-1', periods=5, freq='B')  3

In [25]: index
Out[25]: DatetimeIndex(['2021-07-01', '2021-07-02', '2021-07-05',
          '2021-07-06',
                        '2021-07-07'],
                       dtype='datetime64[ns]', freq='B')

In [26]: df = pd.DataFrame(a, columns=columns, index=index)  4

In [27]: df
Out[27]:              a   b   c
         2021-07-01   0   1   2
         2021-07-02   3   4   5
         2021-07-05   6   7   8
         2021-07-06   9  10  11
         2021-07-07  12  13  14
1

Importa el paquete pandas.

2

Crea un objeto list a partir del objeto str.

3

Se crea un objeto pandas DatetimeIndex que tiene una frecuencia de "día laborable" y abarca cinco periodos.

4

Se instancia un objeto DataFrame basado en el objeto ndarray a con las etiquetas de columna y los valores de índice especificados.

En principio, la vectorización funciona ahora de forma similar a los objetos de ndarray. Una diferencia es que las operaciones de agregación dan por defecto resultados por columnas:

In [28]: 2 * df  1
Out[28]:              a   b   c
         2021-07-01   0   2   4
         2021-07-02   6   8  10
         2021-07-05  12  14  16
         2021-07-06  18  20  22
         2021-07-07  24  26  28

In [29]: df.sum()  2
Out[29]: a    30
         b    35
         c    40
         dtype: int64

In [30]: np.mean(df)  3
Out[30]: a    6.0
         b    7.0
         c    8.0
         dtype: float64
1

Calcula el producto escalar del objeto DataFrame (tratado como una matriz).

2

Calcula la suma por columna.

3

Calcula la media por columna.

Las operaciones por columnas pueden realizarse haciendo referencia a los nombres de las columnas respectivas, mediante la notación de corchetes o la notación de puntos:

In [31]: df['a'] + df['c']  1
Out[31]: 2021-07-01     2
         2021-07-02     8
         2021-07-05    14
         2021-07-06    20
         2021-07-07    26
         Freq: B, dtype: int64

In [32]: 0.5 * df.a + 2 * df.b - df.c  2
Out[32]: 2021-07-01     0.0
         2021-07-02     4.5
         2021-07-05     9.0
         2021-07-06    13.5
         2021-07-07    18.0
         Freq: B, dtype: float64
1

Calcula la suma por elementos de las columnas a y c.

2

Calcula una transformación lineal que incluya las tres columnas.

Del mismo modo, las condiciones que producen vectores de resultados booleanos y las selecciones tipo SQL basadas en dichas condiciones son fáciles de implementar:

In [33]: df['a'] > 5  1
Out[33]: 2021-07-01    False
         2021-07-02    False
         2021-07-05     True
         2021-07-06     True
         2021-07-07     True
         Freq: B, Name: a, dtype: bool

In [34]: df[df['a'] > 5]  2
Out[34]:              a   b   c
         2021-07-05   6   7   8
         2021-07-06   9  10  11
         2021-07-07  12  13  14
1

¿Qué elemento de la columna a es mayor que cinco?

2

Selecciona todas las filas en las que el elemento de la columna a sea mayor que cinco.

Para un backtesting vectorizado de estrategias de negociación, son típicas las comparaciones entre dos columnas o más:

In [35]: df['c'] > df['b']  1
Out[35]: 2021-07-01    True
         2021-07-02    True
         2021-07-05    True
         2021-07-06    True
         2021-07-07    True
         Freq: B, dtype: bool

In [36]: 0.15 * df.a + df.b > df.c  2
Out[36]: 2021-07-01    False
         2021-07-02    False
         2021-07-05    False
         2021-07-06     True
         2021-07-07     True
         Freq: B, dtype: bool
1

¿Para qué fecha el elemento de la columna c es mayor que el de la columna b?

2

Condición que compara una combinación lineal de las columnas a y b con la columna c.

La vectorización con pandas es un concepto potente, en particular para la aplicación de algoritmos financieros y el backtesting vectorizado, como se ilustra en el resto de este capítulo. Para más información sobre los fundamentos de la vectorización con pandas y ejemplos financieros, consulta Hilpisch (2018, cap. 5).

Mientras que NumPy aporta enfoques generales de vectorización al mundo de la computación numérica de Python, pandas permite la vectorización sobre datos de series temporales. Esto es realmente útil para la implementación de algoritmos financieros y el backtesting de estrategias algorítmicas de trading. Al utilizar este enfoque, puedes esperar un código conciso, así como una ejecución más rápida del código, en comparación con el código Python estándar, que hace uso de los bucles for y modismos similares para lograr el mismo objetivo.

Estrategias basadas en medias móviles simples

La negociación basada en medias móviles simples (SMA) es una estrategia con décadas de antigüedad que tiene sus orígenes en el mundo del análisis técnico bursátil. Brock et al. (1992), por ejemplo, investigan empíricamente este tipo de estrategias de forma sistemática. Escriben

El término "análisis técnico" es un encabezamiento general para una miríada de técnicas de negociación....En este artículo, exploramos dos de las reglas técnicas más sencillas y populares: la media móvil-oscilador y la ruptura del rango de negociación (niveles de resistencia y soporte). En el primer método, las señales de compra y venta se generan mediante dos medias móviles, una de periodo largo y otra de periodo corto....Nuestro estudio revela que el análisis técnico ayuda a predecir los cambios bursátiles.

Entrando en lo básico

Esta subsección se centra en los fundamentos del backtesting de estrategias de negociación que utilizan dos SMA. El ejemplo a seguir funciona con datos de cierre del fin del día (EOD) para el tipo de cambio EUR/USD, tal y como se proporcionan en el archivo csv bajo el archivo de datos EOD. Los datos del conjunto de datos proceden de la API de datos de Refinitiv Eikon y representan valores EOD para los respectivos instrumentos (RICs):

In [37]: raw = pd.read_csv('http://hilpisch.com/pyalgo_eikon_eod_data.csv',
                            index_col=0, parse_dates=True).dropna()  1

In [38]: raw.info()  2
         <class 'pandas.core.frame.DataFrame'>
         DatetimeIndex: 2516 entries, 2010-01-04 to 2019-12-31
         Data columns (total 12 columns):
          #   Column  Non-Null Count  Dtype
         ---  ------  --------------  -----
          0   AAPL.O  2516 non-null   float64
          1   MSFT.O  2516 non-null   float64
          2   INTC.O  2516 non-null   float64
          3   AMZN.O  2516 non-null   float64
          4   GS.N    2516 non-null   float64
          5   SPY     2516 non-null   float64
          6   .SPX    2516 non-null   float64
          7   .VIX    2516 non-null   float64
          8   EUR=    2516 non-null   float64
          9   XAU=    2516 non-null   float64
          10  GDX     2516 non-null   float64
          11  GLD     2516 non-null   float64
         dtypes: float64(12)
         memory usage: 255.5 KB

In [39]: data = pd.DataFrame(raw['EUR='])  3

In [40]: data.rename(columns={'EUR=': 'price'}, inplace=True)  4

In [41]: data.info()  5
         <class 'pandas.core.frame.DataFrame'>
         DatetimeIndex: 2516 entries, 2010-01-04 to 2019-12-31
         Data columns (total 1 columns):
          #   Column  Non-Null Count  Dtype
         ---  ------  --------------  -----
          0   price   2516 non-null   float64
         dtypes: float64(1)
         memory usage: 39.3 KB
1

Lee los datos del archivo CSV almacenado remotamente.

2

Muestra la metainformación del objeto DataFrame.

3

Transforma el objeto Series en un objeto DataFrame.

4

Cambia el nombre de la única columna a price.

5

Muestra la metainformación del nuevo objeto DataFrame.

El cálculo de los SMA se simplifica mediante el método rolling(), en combinación con una operación de cálculo diferido:

In [42]: data['SMA1'] = data['price'].rolling(42).mean()  1

In [43]: data['SMA2'] = data['price'].rolling(252).mean()  2

In [44]: data.tail()  3
Out[44]:              price      SMA1      SMA2
         Date
         2019-12-24  1.1087  1.107698  1.119630
         2019-12-26  1.1096  1.107740  1.119529
         2019-12-27  1.1175  1.107924  1.119428
         2019-12-30  1.1197  1.108131  1.119333
         2019-12-31  1.1210  1.108279  1.119231
1

Crea una columna con 42 días de valores de SMA. Los 41 primeros valores serán NaN.

2

Crea una columna con 252 días de valores SMA. Los 251 primeros valores serán NaN.

3

Imprime las cinco últimas filas del conjunto de datos.

Una visualización de los datos de la serie temporal original en combinación con los SMA ilustra mejor los resultados (ver Figura 4-1):

In [45]: %matplotlib inline
         from pylab import mpl, plt
         plt.style.use('seaborn')
         mpl.rcParams['savefig.dpi'] = 300
         mpl.rcParams['font.family'] = 'serif'

In [46]: data.plot(title='EUR/USD | 42 & 252 days SMAs',
                   figsize=(10, 6));

El siguiente paso es generar señales, o más bien posicionamientos de mercado, basados en la relación entre las dos SMA. La regla es ir en largo siempre que la SMA más corta esté por encima de la más larga y viceversa. A nuestros efectos, indicamos una posición larga con 1 y una posición corta con -1.

pfat 0401
Figura 4-1. El tipo de cambio EUR/USD con dos SMAs

Poder comparar directamente dos columnas del objeto DataFrame hace que la implementación de la regla sea cosa de una sola línea de código. El posicionamiento en el tiempo se ilustra en la Figura 4-2:

In [47]: data['position'] = np.where(data['SMA1'] > data['SMA2'],
                                     1, -1)  1

In [48]: data.dropna(inplace=True)  2

In [49]: data['position'].plot(ylim=[-1.1, 1.1],
                               title='Market Positioning',
                               figsize=(10, 6));  3
1

Implementa la regla de negociación de forma vectorizada. np.where() produce +1 para las filas en las que la expresión es True y -1 para las filas en las que la expresión es False.

2

Elimina todas las filas del conjunto de datos que contengan al menos un valor NaN.

3

Traza el posicionamiento a lo largo del tiempo.

pfat 0402
Figura 4-2. Posicionamiento en el mercado basado en la estrategia con dos SMA

Para calcular el rendimiento de la estrategia, calcula a continuación los rendimientos logarítmicos basados en la serie temporal financiera original. El código para hacerlo es, de nuevo, bastante conciso debido a la vectorización. La Figura 4-3 muestra el histograma de los rendimientos logarítmicos:

In [50]: data['returns'] = np.log(data['price'] / data['price'].shift(1))  1

In [51]: data['returns'].hist(bins=35, figsize=(10, 6));  2
1

Calcula los rendimientos logarítmicos de forma vectorizada sobre la columna price.

2

Traza los retornos logarítmicos como un histograma (distribución de frecuencias).

Para obtener los rendimientos de la estrategia, multiplica la columna position -desplazada un día de negociación- por la columna returns. Como los rendimientos logarítmicos son aditivos, calcular la suma de las columnas returns y strategy proporciona una primera comparación del rendimiento de la estrategia en relación con la propia inversión base.

pfat 0403
Figura 4-3. Distribución de frecuencias de los rendimientos logarítmicos del EUR/USD

La comparación de los rendimientos muestra que la estrategia gana a la inversión pasiva de referencia:

In [52]: data['strategy'] = data['position'].shift(1) * data['returns']  1

In [53]: data[['returns', 'strategy']].sum()  2
Out[53]: returns    -0.176731
         strategy    0.253121
         dtype: float64

In [54]: data[['returns', 'strategy']].sum().apply(np.exp)  3
Out[54]: returns     0.838006
         strategy    1.288039
         dtype: float64
1

Deriva los rendimientos logarítmicos de la estrategia dados los posicionamientos y los rendimientos del mercado.

2

Suma los valores de rentabilidad logarítmica única tanto de la acción como de la estrategia (sólo a título ilustrativo).

3

Aplica la función exponencial a la suma de los rendimientos logarítmicos para calcular el rendimiento bruto.

El cálculo de la suma acumulada a lo largo del tiempo con cumsum y, a partir de ella, de los rendimientos acumulados aplicando la función exponencial np.exp() ofrece una imagen más completa de cómo se compara la estrategia con el rendimiento del instrumento financiero base a lo largo del tiempo. La Figura 4-4 muestra los datos gráficamente e ilustra el rendimiento superior en este caso concreto:

In [55]: data[['returns', 'strategy']].cumsum(
                     ).apply(np.exp).plot(figsize=(10, 6));
pfat 0404
Figura 4-4. Rendimiento bruto del EUR/USD comparado con la estrategia basada en SMA

Las estadísticas medias anualizadas de riesgo-rentabilidad, tanto de las acciones como de la estrategia, son fáciles de calcular:

In [56]: data[['returns', 'strategy']].mean() * 252  1
Out[56]: returns    -0.019671
         strategy    0.028174
         dtype: float64

In [57]: np.exp(data[['returns', 'strategy']].mean() * 252) - 1  1
Out[57]: returns    -0.019479
         strategy    0.028575
         dtype: float64

In [58]: data[['returns', 'strategy']].std() * 252 ** 0.5  2
Out[58]: returns     0.085414
         strategy    0.085405
         dtype: float64

In [59]: (data[['returns', 'strategy']].apply(np.exp) - 1).std() * 252 ** 0.5  2
Out[59]: returns     0.085405
         strategy    0.085373
         dtype: float64
1

Calcula la rentabilidad media anualizada en espacio logarítmico y regular.

2

Calcula la desviación típica anualizada en espacio logarítmico y regular.

Otras estadísticas de riesgo que suelen interesar en el contexto de los rendimientos de las estrategias de negociación son la reducción máxima y el periodo de reducción más largo. Una estadística de ayuda que se puede utilizar en este contexto es el rendimiento bruto máximo acumulado, calculado mediante el método cummax() aplicado al rendimiento bruto de la estrategia. La Figura 4-5 muestra las dos series temporales de la estrategia basada en SMA:

In [60]: data['cumret'] = data['strategy'].cumsum().apply(np.exp)  1

In [61]: data['cummax'] = data['cumret'].cummax()  2

In [62]: data[['cumret', 'cummax']].dropna().plot(figsize=(10, 6));  3
1

Define una nueva columna, cumret, con el rendimiento bruto a lo largo del tiempo.

2

Define otra columna con el valor máximo en funcionamiento delrendimiento bruto.

3

Traza las dos nuevas columnas del objeto DataFrame.

pfat 0405
Figura 4-5. Rendimiento bruto y rendimiento máximo acumulado de la estrategia basada en el SMA

La reducción máxima se calcula simplemente como el máximo de la diferencia entre las dos columnas correspondientes. En el ejemplo, la reducción máxima es de unos 18 puntos porcentuales:

In [63]: drawdown = data['cummax'] - data['cumret']  1

In [64]: drawdown.max()  2
Out[64]: 0.17779367070195917
1

Calcula la diferencia por elementos entre las dos columnas.

2

Selecciona el valor máximo de todas las diferencias.

La determinación del periodo de reducción más largo es un poco más complicada. Requiere aquellas fechas en las que el rendimiento bruto es igual a su máximo acumulado (es decir, en las que se establece un nuevo máximo). Esta información se almacena en un objeto temporal. Después se calculan las diferencias en días entre todas esas fechas y se elige el periodo más largo. Estos periodos pueden ser de sólo un día o de más de 100 días. En este caso, el periodo de reducción más largo dura 596 días, un periodo bastante largo:2

In [65]: temp = drawdown[drawdown == 0]  1

In [66]: periods = (temp.index[1:].to_pydatetime() -
                    temp.index[:-1].to_pydatetime())  2

In [67]: periods[12:15]
Out[67]: array([datetime.timedelta(days=1), datetime.timedelta(days=1),
                datetime.timedelta(days=10)], dtype=object)

In [68]: periods.max()  3
Out[68]: datetime.timedelta(days=596)
1

¿Dónde están las diferencias iguales a cero?

2

Calcula los valores de timedelta entre todos los valores del índice.

3

Selecciona el valor máximo de timedelta.

El backtesting vectorizado con pandas suele ser una tarea bastante eficiente gracias a las capacidades del paquete y de la clase principal DataFrame. Sin embargo, el enfoque interactivo ilustrado hasta ahora no funciona bien cuando se desea implementar un programa de backtesting más amplio que, por ejemplo, optimice los parámetros de una estrategia basada en SMA. Para ello, es aconsejable un enfoque más general.

pandas resulta ser una potente herramienta para el análisis vectorizado de estrategias de negociación. Muchas estadísticas de interés, como los rendimientos logarítmicos, los rendimientos acumulados, los rendimientos anualizados y la volatilidad, la reducción máxima y el periodo de reducción máxima, pueden calcularse, en general, con una sola línea o unas pocas líneas de código. Poder visualizar los resultados mediante una simple llamada a un método es una ventaja adicional.

Generalizar el enfoque

"Clase de backtesting de SMA" presenta un código Python que contiene una clase para el backtesting vectorizado de estrategias de negociación basadas en SMA. En cierto sentido, es una generalización del enfoque presentado en la subsección anterior. Permite definir una instancia de la clase SMAVectorBacktester proporcionando los siguientes parámetros:

  • symbol: RIC (datos del instrumento) a utilizar

  • SMA1para la ventana temporal en días para la SMA más corta

  • SMA2para la ventana temporal en días para la SMA más larga

  • start: para la fecha de inicio de la selección de datos

  • end: para la fecha final de la selección de datos

La mejor forma de ilustrar la aplicación en sí es mediante una sesión interactiva que hace uso de la clase. El ejemplo replica primero el backtest implementado anteriormente basado en datos del tipo de cambio EUR/USD. A continuación, optimiza los parámetros del SMA para obtener el máximo rendimiento bruto. Basándose en los parámetros óptimos, traza elrendimiento bruto resultante de la estrategia en comparación con el instrumento base durante el periodode tiempo correspondiente:

In [69]: import SMAVectorBacktester as SMA  1

In [70]: smabt = SMA.SMAVectorBacktester('EUR=', 42, 252,
                                         '2010-1-1', '2019-12-31')   2

In [71]: smabt.run_strategy()  3
Out[71]: (1.29, 0.45)

In [72]: %%time
         smabt.optimize_parameters((30, 50, 2),
                                   (200, 300, 2))  4
         CPU times: user 3.76 s, sys: 15.8 ms, total: 3.78 s
         Wall time: 3.78 s

Out[72]: (array([ 48., 238.]), 1.5)

In [73]: smabt.plot_results()  5
1

Esto importa el módulo como SMA.

2

Se crea una instancia de la clase principal.

3

Realiza una prueba retrospectiva de la estrategia basada en SMA, dados los parámetros durante la instanciación.

4

El método optimize_parameters() toma como entrada rangos de parámetros con tamaños de paso y determina la combinación óptima mediante un enfoque de fuerza bruta.

5

El método plot_results() traza el rendimiento de la estrategia en comparación con el instrumento de referencia, dados los valores de los parámetros almacenados actualmente (aquí del procedimiento de optimización).

El rendimiento bruto de la estrategia con la parametrización original es de 1,24 o 124%. La estrategia optimizada produce un rendimiento absoluto de 1,44 o 144% para la combinación de parámetros SMA1 = 48 y SMA2 = 238. La Figura 4-6 muestra gráficamente el rendimiento bruto a lo largo del tiempo, de nuevo comparado con el rendimiento del instrumento base, que representa el valor de referencia.

pfat 0406
Figura 4-6. Rendimiento bruto del EUR/USD y la estrategia SMA optimizada

Estrategias basadas en el impulso

Hay dos tipos básicos de estrategias de impulso. El primer tipo son las estrategias de impulso transversal. Seleccionando entre un conjunto más amplio de instrumentos, estas estrategias compran los instrumentos que recientemente han obtenido mejores resultados que sus homólogos (o un índice de referencia) y venden los instrumentos que han obtenido peores resultados. La idea básica es que los instrumentos sigan teniendo un rendimiento superior o inferior, respectivamente, al menos durante un cierto periodo de tiempo. Jegadeesh y Titman (1993, 2001) y Chan et al. (1996) estudian este tipo de estrategias de negociación y sus posibles fuentes de beneficios.

Tradicionalmente, las estrategias de impulso transversal han funcionado bastante bien. Jegadeesh y Titman (1993) escriben:

Este documento documenta que las estrategias que compran acciones que han tenido buenos resultados en el pasado y venden acciones que han tenido malos resultados en el pasado generan rendimientos positivos significativos durante periodos de tenencia de 3 a 12 meses.

El segundo tipo son las estrategias de impulso de series temporales. Estas estrategias compran los instrumentos que recientemente han obtenido buenos resultados y venden los instrumentos que recientemente han obtenido malos resultados. En este caso, el punto de referencia son los rendimientos pasados del propio instrumento. Moskowitz et al. (2012) analizan detalladamente este tipo de estrategia de impulso en una amplia gama de mercados. Escriben

En lugar de centrarse en los rendimientos relativos de los valores en el corte transversal, el impulso de las series temporales se centra exclusivamente en el rendimiento pasado de un valor..... Nuestra observación del impulso de las series temporales en prácticamente todos los instrumentos que examinamos parece cuestionar la hipótesis del "paseo aleatorio", que en su forma más básica implica que saber si un precio subió o bajó en el pasado no debería ser informativo sobre si subirá o bajará en el futuro.

Entrando en lo básico

Considera precios de cierre al final del día para el precio del oro en USD (XAU=):

In [74]: data = pd.DataFrame(raw['XAU='])

In [75]: data.rename(columns={'XAU=': 'price'}, inplace=True)

In [76]: data['returns'] = np.log(data['price'] / data['price'].shift(1))

La estrategia de impulso de series temporales más sencilla consiste en comprar la acción si el último rendimiento fue positivo y venderla si fue negativo. Con NumPy y pandas esto es fácil de formalizar; basta con tomar el signo de la última rentabilidad disponible como posición de mercado. La Figura 4-7 ilustra el rendimiento de esta estrategia. La estrategia tiene un rendimiento significativamente inferior al instrumento base:

In [77]: data['position'] = np.sign(data['returns'])  1

In [78]: data['strategy'] = data['position'].shift(1) * data['returns']  2

In [79]: data[['returns', 'strategy']].dropna().cumsum(
                     ).apply(np.exp).plot(figsize=(10, 6));  3
1

Define una nueva columna con el signo (es decir, 1 ó -1) de la rentabilidad logarítmica correspondiente; los valores resultantes representan las posiciones en el mercado (largas o cortas).

2

Calcula los rendimientos logarítmicos de la estrategia dados los posicionamientos del mercado.

3

Traza y compara el rendimiento de la estrategia con el instrumento de referencia.

pfat 0407
Figura 4-7. Rentabilidad bruta del precio del oro (USD) y de la estrategia de impulso (sólo última rentabilidad)

Utilizando una ventana temporal móvil, la estrategia de impulso de series temporales puede generalizarse a algo más que el último rendimiento. Por ejemplo, se puede utilizar la media de las tres últimas rentabilidades para generar la señal de posicionamiento. La Figura 4-8 muestra que, en este caso, la estrategia lo hace mucho mejor, tanto en términos absolutos como en relación con elinstrumento base:

In [80]: data['position'] = np.sign(data['returns'].rolling(3).mean())  1

In [81]: data['strategy'] = data['position'].shift(1) * data['returns']

In [82]: data[['returns', 'strategy']].dropna().cumsum(
                 ).apply(np.exp).plot(figsize=(10, 6));
1

Esta vez, se toma el rendimiento medio de una ventana móvil de tres días.

Sin embargo, el rendimiento es bastante sensible al parámetro de la ventana temporal. Elegir, por ejemplo, los dos últimos rendimientos en lugar de tres conduce a un rendimiento mucho peor, como se muestra en la Figura 4-9.

pfat 0408
Figura 4-8. Rentabilidad bruta del precio del oro (USD) y de la estrategia de impulso (tres últimas rentabilidades)
pfat 0409
Figura 4-9. Rentabilidad bruta del precio del oro (USD) y de la estrategia de impulso (dos últimas rentabilidades)

El impulso de las series temporales también podría esperarse intradía. En realidad, cabría esperar que fuera más pronunciado intradía que interdía. La Figura 4-10 muestra el rendimiento bruto de cinco estrategias de impulso de series temporales para una, tres, cinco, siete y nueve observaciones de rentabilidad, respectivamente. Los datos utilizados son los precios intradía de las acciones de Apple Inc. recuperados de la API de datos de Eikon. La figura se basa en el código que sigue. Básicamente, todas las estrategias superan a la acción en el transcurso de esta ventana temporal intradiaria, aunque algunas sólo ligeramente:

In [83]: fn = '../data/AAPL_1min_05052020.csv'  1
         # fn = '../data/SPX_1min_05052020.csv'  1

In [84]: data = pd.read_csv(fn, index_col=0, parse_dates=True)  1

In [85]: data.info()  1
         <class 'pandas.core.frame.DataFrame'>
         DatetimeIndex: 241 entries, 2020-05-05 16:00:00 to 2020-05-05 20:00:00
         Data columns (total 6 columns):
          #   Column  Non-Null Count  Dtype
         ---  ------  --------------  -----
          0   HIGH    241 non-null    float64
          1   LOW     241 non-null    float64
          2   OPEN    241 non-null    float64
          3   CLOSE   241 non-null    float64
          4   COUNT   241 non-null    float64
          5   VOLUME  241 non-null    float64
         dtypes: float64(6)
         memory usage: 13.2 KB

In [86]: data['returns'] = np.log(data['CLOSE'] /
                                  data['CLOSE'].shift(1))  2

In [87]: to_plot = ['returns']  3

In [88]: for m in [1, 3, 5, 7, 9]:
             data['position_%d' % m] = np.sign(data['returns'].rolling(m).mean())  4
             data['strategy_%d' % m] = (data['position_%d' % m].shift(1) *
                                        data['returns'])  5
             to_plot.append('strategy_%d' % m)   6

In [89]: data[to_plot].dropna().cumsum().apply(np.exp).plot(
             title='AAPL intraday 05. May 2020',
             figsize=(10, 6), style=['-', '--', '--', '--', '--', '--']);  7
1

Lee los datos intradiarios de un archivo CSV.

2

Calcula los rendimientos log intradía.

3

Define un objeto list para seleccionar las columnas que se trazarán posteriormente.

4

Deriva posicionamientos según el parámetro de la estrategia de impulso.

5

Calcula los rendimientos logarítmicos de la estrategia resultante.

6

Añade el nombre de la columna al objeto list.

7

Traza todas las columnas relevantes para comparar el rendimiento de las estrategias con el rendimiento del instrumento de referencia.

pfat 0410
Figura 4-10. Rendimiento bruto intradía de las acciones de Apple y de cinco estrategias de impulso (últimos uno, tres, cinco, siete y nueve rendimientos)

La Figura 4-11 muestra el rendimiento de las mismas cinco estrategias para el índice S&P 500. De nuevo, las cinco configuraciones de estrategias superan al índice y todas muestran un rendimiento positivo (antes de los costes de transacción).

pfat 0411
Figura 4-11. Rentabilidad bruta intradía del índice S&P 500 y de cinco estrategias de impulso (últimos uno, tres, cinco, siete y nueve rendimientos)

Generalizar el enfoque

"Momentum Backtesting Class" presenta un módulo Python que contiene la clase MomVectorBacktester, que permite realizar un backtesting un poco más estandarizado de las estrategias basadas en el impulso. La clase tiene los siguientes atributos:

  • symbol: RIC (datos del instrumento) a utilizar

  • start: para la fecha de inicio de la selección de datos

  • end: para la fecha final de la selección de datos

  • amount: para la cantidad inicial a invertir

  • tcpara los costes de transacción proporcionales por operación

En comparación con la clase SMAVectorBacktester, ésta introduce dos generalizaciones importantes: la cantidad fija que hay que invertir al principio del periodo de backtesting y los costes de transacción proporcionales para acercarse más a la realidad del mercado en cuanto a costes. En particular, la adición de los costes de transacción es importante en el contexto de las estrategias de impulso de series temporales, que a menudo dan lugar a un gran número de transacciones a lo largo del tiempo.

La aplicación es tan sencilla y cómoda como antes. El ejemplo replica primero los resultados de la sesión interactiva anterior, pero esta vez con una inversión inicial de 10.000 USD. La Figura 4-12 visualiza el rendimiento de la estrategia, tomando la media de los tres últimos rendimientos para generar señales para el posicionamiento. El segundo caso contemplado es uno con costes de transacción proporcionales del 0,1% por operación. Como ilustra la Figura 4-13, incluso unos costes de transacción pequeños deterioran significativamente el rendimiento en este caso. El factor determinante a este respecto es la frecuencia relativamente alta de las operaciones que requiere la estrategia:

In [90]: import MomVectorBacktester as Mom  1

In [91]: mombt = Mom.MomVectorBacktester('XAU=', '2010-1-1',
                                         '2019-12-31', 10000, 0.0)  2

In [92]: mombt.run_strategy(momentum=3)  3
Out[92]: (20797.87, 7395.53)

In [93]: mombt.plot_results()
In [94]: mombt = Mom.MomVectorBacktester('XAU=', '2010-1-1',
                                         '2019-12-31', 10000, 0.001)  4

In [95]: mombt.run_strategy(momentum=3)  5
Out[95]: (10749.4, -2652.93)

In [96]: mombt.plot_results()
1

Importa el módulo como Mom

2

Instantiza un objeto de la clase backtesting definiendo que el capital inicial sea de 10.000 USD y que los costes de transacción proporcionales sean cero.

3

Prueba retrospectiva de la estrategia de impulso basada en una ventana temporal de tres días: la estrategia supera a la inversión pasiva de referencia.

4

Esta vez, se suponen unos costes de transacción proporcionales del 0,1% por operación.

5

En ese caso, la estrategia pierde básicamente todo el rendimiento superior.

pfat 0412
Figura 4-12. Rendimiento bruto del precio del oro (USD) y de la estrategia de impulso (tres últimos rendimientos, sin costes de transacción)
pfat 0413
Figura 4-13. Rendimiento bruto del precio del oro (USD) y de la estrategia de impulso (tres últimos rendimientos, costes de transacción del 0,1%)

Estrategias basadas en la reversión a la media

A grandes rasgos, las estrategias de reversión a la media se basan en un razonamiento opuesto al de las estrategias de impulso. Si un instrumento financiero se ha comportado "demasiado bien" en relación con su tendencia, se pone en corto, y viceversa. Dicho de otro modo, mientras que las estrategias de impulso (series temporales) suponen una correlación positiva entre los rendimientos, las estrategias de reversión a la media suponen una correlación negativa. Balvers et al. (2000) escriben:

La reversión a la media se refiere a la tendencia de los precios de los activos a volver a una trayectoria tendencial.

Trabajando con una media móvil simple (SMA) como sustituto de una "trayectoria de tendencia", una estrategia de reversión a la media en, por ejemplo, el tipo de cambio EUR/USD puede someterse a pruebas retrospectivas de forma similar a las pruebas retrospectivas de las estrategias basadas en la SMA y en el impulso. La idea es definir un umbral para la distancia entre la cotización actual y la SMA, que señale una posición larga o corta.

Entrando en lo básico

Los ejemplos de que siguen corresponden a dos instrumentos financieros diferentes para los que cabría esperar una reversión a la media significativa, ya que ambos se basan en el precio del oro:

El ejemplo comienza con GDX e implementa una estrategia de reversión a la media sobre la base de una SMA de 25 días y un valor umbral de 3,5 para que la desviación absoluta del precio actual se desvíe de la SMA para señalar un posicionamiento. La Figura 4-14 muestra las diferencias entre el precio actual de GDX y la SMA, así como el valor umbral positivo y negativo para generar señales de venta y compra, respectivamente:

In [97]: data = pd.DataFrame(raw['GDX'])

In [98]: data.rename(columns={'GDX': 'price'}, inplace=True)

In [99]: data['returns'] = np.log(data['price'] /
                                  data['price'].shift(1))

In [100]: SMA = 25  1

In [101]: data['SMA'] = data['price'].rolling(SMA).mean()  2

In [102]: threshold = 3.5  3

In [103]: data['distance'] = data['price'] - data['SMA']  4

In [104]: data['distance'].dropna().plot(figsize=(10, 6), legend=True)  5
          plt.axhline(threshold, color='r')
          plt.axhline(-threshold, color='r')
          plt.axhline(0, color='r');
1

El parámetro SMA se define...

2

...y se calcula la SMA ("trayectoria de tendencia").

3

Se define el umbral para la generación de la señal.

4

La distancia se calcula para cada punto en el tiempo.

5

Se trazan los valores de distancia.

pfat 0414
Figura 4-14. Diferencia entre el precio actual de GDX y la SMA, así como los valores umbral para generar señales de reversión a la media

A partir de las diferencias y de los valores umbral fijados, se pueden derivar de nuevo posicionamientos de forma vectorizada. La Figura 4-15 muestra los posicionamientos resultantes:

In [105]: data['position'] = np.where(data['distance'] > threshold,
                                      -1, np.nan)  1

In [106]: data['position'] = np.where(data['distance'] < -threshold,
                                      1, data['position'])  2

In [107]: data['position'] = np.where(data['distance'] *
                      data['distance'].shift(1) < 0, 0, data['position'])  3

In [108]: data['position'] = data['position'].ffill().fillna(0)  4

In [109]: data['position'].iloc[SMA:].plot(ylim=[-1.1, 1.1],
                                         figsize=(10, 6));  5
1

Si el valor de la distancia es mayor que el valor umbral, ponte corto (pon -1 en la nueva columna position), de lo contrario pon NaN.

2

Si el valor de la distancia es inferior al valor del umbral negativo, ve a largo (pon 1); de lo contrario, mantén la columna position sin cambios.

3

Si hay un cambio en el signo del valor de la distancia, pasa a mercado neutral (pon 0), de lo contrario mantén la columna position sin cambios.

4

Rellena hacia delante todas las posiciones NaN con los valores anteriores; sustituye todos los valores NaN restantes por 0.

5

Traza las posiciones resultantes a partir de la posición índice SMA.

pfat 0415
Figura 4-15. Posicionamientos generados para GDX según la estrategia de reversión a la media

El último paso es obtener los rendimientos de la estrategia, que se muestran en la Figura 4-16. La estrategia supera al ETF GDX por bastante margen, aunque la parametrización particular conduce a largos periodos con una posición neutral (ni larga ni corta). Estas posiciones neutrales se reflejan en las partes planas de la curva de la estrategia en la Figura 4-16:

In [110]: data['strategy'] = data['position'].shift(1) * data['returns']

In [111]: data[['returns', 'strategy']].dropna().cumsum(
                  ).apply(np.exp).plot(figsize=(10, 6));
pfat 0416
Figura 4-16. Rentabilidad bruta del ETF GDX y de la estrategia de reversión a la media (SMA = 25, umbral = 3,5)

Generalizar el enfoque

Como antes, el backtesting vectorizado es más eficiente de implementar basándose en una clase Python respectiva. La clase MRVectorBacktester presentada en "Clase de backtesting de reversión a la media " hereda de la clase MomVectorBacktester y sólo sustituye el método run_strategy() para adaptarse a las particularidades de la estrategia de reversión a la media.

El ejemplo utiliza ahora GLD y fija los costes de transacción proporcionales en 0,1%. La cantidad inicial a invertir se fija de nuevo en 10.000 USD. Esta vez la SMA es 43, y el valor umbral se fija en 7,5. La Figura 4-17 muestra el rendimiento de la estrategia de reversión a la media en comparación con el ETF GLD:

In [112]: import MRVectorBacktester as MR  1

In [113]: mrbt = MR.MRVectorBacktester('GLD', '2010-1-1', '2019-12-31',
                                       10000, 0.001)  2

In [114]: mrbt.run_strategy(SMA=43, threshold=7.5)  3
Out[114]: (13542.15, 646.21)

In [115]: mrbt.plot_results()  4
1

Importa el módulo como MR.

2

Instantiza un objeto de la clase MRVectorBacktester con un capital inicial de 10.000 USD y unos costes de transacción proporcionales del 0,1% por operación; la estrategia supera significativamente al instrumento de referencia en este caso.

3

Realiza una prueba retrospectiva de la estrategia de reversión a la media con un valor SMA de 43 y un valor threshold de 7,5.

4

Traza el rendimiento acumulado de la estrategia frente al instrumento base .

pfat 0417
Figura 4-17. Rentabilidad bruta del ETF GLD y de la estrategia de reversión a la media (SMA = 43, umbral = 7,5, costes de transacción del 0,1%).

Espionaje de datos y sobreajuste

En este capítulo, así como en el resto del libro, se hace hincapié en la aplicación tecnológica de conceptos importantes del trading algorítmico mediante el uso de Python. Las estrategias, los parámetros, los conjuntos de datos y los algoritmos utilizados se eligen a veces de forma arbitraria y a veces de forma intencionada para demostrar algo. Sin duda, al hablar de métodos técnicos aplicados a las finanzas, es más emocionante ymotivador ver ejemplos que muestran "buenos resultados", aunque no sean generalizables en otros instrumentos financieros o periodos de tiempo, por ejemplo.

La capacidad de mostrar ejemplos con buenos resultados suele producirse a costa del espionaje de datos. Según White (2000), el espionaje de datos puede definirse del siguiente modo:

El snooping de datos se produce cuando un determinado conjunto de datos se utiliza más de una vez con fines de inferencia o selección de modelos.

En otras palabras, un determinado enfoque puede aplicarse varias o incluso muchas veces al mismo conjunto de datos para llegar a cifras y gráficos satisfactorios. Esto, por supuesto, es intelectualmente deshonesto en la investigación de estrategias de negociación, porque pretende que una estrategia de negociación tiene un potencial económico que podría no ser realista en un contexto del mundo real. Dado que este libro se centra en el uso de Python como lenguaje de programación para el trading algorítmico, el enfoque de espionaje de datos podría estar justificado. Esto es análogo a un libro de matemáticas que, a modo de ejemplo, resuelve una ecuación que tiene una solución única que puede identificarse fácilmente. En matemáticas, estos ejemplos sencillos son más bien la excepción que la regla, pero sin embargo se utilizan con frecuencia con fines didácticos.

Otro problema que surge en este contexto es el sobreajuste. El sobreajuste en uncontexto de negociación puede describirse del siguiente modo (véase el Instituto Man sobre Sobreajuste):

La sobreadaptación se produce cuando un modelo describe ruido en lugar de señal. El modelo puede tener un buen rendimiento con los datos con los que se probó, pero poco o ningún poder predictivo con nuevos datos en el futuro. El sobreajuste puede describirse como la búsqueda de patrones que en realidad no existen. El sobreajuste tiene un coste: una estrategia sobreajustada tendrá un rendimiento inferior en el futuro.

Incluso una estrategia sencilla, como la basada en dos valores de SMA, permite realizar backtesting de miles de combinaciones de parámetros diferentes. Es casi seguro que algunas de esas combinaciones muestren buenos resultados de rendimiento. Como explican detalladamente Bailey et al. (2015), esto conduce fácilmente a un sobreajuste del backtest, sin que los responsables del mismo sean conscientes del problema. Señalan

Los recientes avances en la investigación algorítmica y la informática de alto rendimiento han hecho que sea casi trivial probar millones y miles de millones de estrategias de inversión alternativas en un conjunto finito de datos de series temporales financieras....[E]s práctica común utilizar esta potencia computacional para calibrar los parámetros de una estrategia de inversión con el fin de maximizar su rendimiento. Pero como la relación señal-ruido es tan débil, a menudo el resultado de dicha calibración es que los parámetros se eligen para sacar provecho del ruido pasado en lugar de la señal futura. El resultado es un backtest sobreajustado.

El problema de la validez de los resultados empíricos, en sentido estadístico, no se limita, por supuesto, al backtesting de estrategias en un contexto financiero.

Ioannidis (2005), refiriéndose a las publicaciones médicas, hace hincapié en las consideraciones probabilísticas y estadísticas a la hora de juzgar la reproducibilidad y validez de los resultados de la investigación:

Cada vez preocupa más que, en la investigación moderna, los resultados falsos puedan ser la mayoría o incluso la inmensa mayoría de las afirmaciones de investigación publicadas. Sin embargo, esto no debería sorprender. Puede demostrarse que la mayoría de los resultados de investigación que se afirman son falsos.... Como se ha demostrado anteriormente, la probabilidad de que un resultado de investigación sea realmente cierto depende de la probabilidad previa de que lo sea (antes de hacer el estudio), de la potencia estadística del estudio y del nivel de significación estadística.

En este contexto, si se demuestra que una estrategia de negociación de este libro funciona bien dado un determinado conjunto de datos, una combinación de parámetros y quizá un algoritmo específico de aprendizaje automático, esto no constituye ningún tipo de recomendación para la configuración concreta ni permite extraer conclusiones más generales sobre la calidad y el potencial de rendimiento de la configuración de la estrategia en cuestión.

Por supuesto, te animamos a que utilices el código y los ejemplos presentados en este libro para explorar tus propias ideas de estrategias de negociación algorítmica e implementarlas en la práctica basándote en tus propios resultados de backtesting, validaciones y conclusiones. Al fin y al cabo, lo que los mercados financieros compensarán es una investigación adecuada y diligente de las estrategias, no el fisgoneo de datos y el sobreajuste impulsados por la fuerza bruta.

Conclusiones

La vectorización es un concepto poderoso en la informática científica, así como para la analítica financiera, en el contexto del backtesting de estrategias algorítmicas de negociación. Este capítulo presenta la vectorización tanto en NumPy como en pandas, y la aplica al backtesting de tres tipos de estrategias de negociación: estrategias basadas en medias móviles simples, en el impulso y en la reversión a la media. Hay que admitir que el capítulo hace una serie de suposiciones simplificadoras, y que un backtesting riguroso de las estrategias de negociación debe tener en cuenta más factores que determinan el éxito de la negociación en la práctica, como los problemas de los datos, los problemas de selección, la evitación del sobreajuste o los elementos de microestructura del mercado. Sin embargo, el objetivo principal del capítulo es centrarse en el concepto de vectorización y lo que puede hacer en la negociación algorítmica desde un punto de vista tecnológico y de implementación. Con respecto a todos los ejemplos y resultados concretos presentados, hay que tener en cuenta los problemas del espionaje de datos, el sobreajuste y la significación estadística.

Referencias y otros recursos

Para conocer los fundamentos de la vectorización con NumPy y pandas, consulta estos libros:

Para el uso de NumPy y pandas en un contexto financiero, consulta estos libros:

Para los temas del espionaje de datos y el sobreajuste, consulta estos documentos:

Para más información de fondo y resultados empíricos sobre estrategias de negociación basadas en medias móviles simples, consulta estas fuentes:

El libro de Ernest Chan trata en detalle las estrategias de negociación basadas en el impulso, así como en la reversión a la media. El libro también es una buena fuente de información sobre las trampas del backtesting de estrategias de negociación:

Estos trabajos de investigación analizan las características y las fuentes de beneficios de las estrategias de impulso transversal, el enfoque tradicional de la negociación basada en el impulso:

El artículo de Moskowitz et al. ofrece un análisis de las llamadas estrategias de impulso de series temporales:

Estos trabajos analizan empíricamente la reversión a la media en los precios de los activos:

Scripts de Python

Esta sección presenta los scripts de Python a los que se hace referencia y que se utilizan en este capítulo.

Clase de Backtesting SMA

A continuación se presenta un código Python con una clase para el backtesting vectorizado de estrategias basadas en medias móviles simples:

#
# Python Module with Class
# for Vectorized Backtesting
# of SMA-based Strategies
#
# Python for Algorithmic Trading
# (c) Dr. Yves J. Hilpisch
# The Python Quants GmbH
#
import numpy as np
import pandas as pd
from scipy.optimize import brute


class SMAVectorBacktester(object):
    ''' Class for the vectorized backtesting of SMA-based trading strategies.

    Attributes
    ==========
    symbol: str
        RIC symbol with which to work
    SMA1: int
        time window in days for shorter SMA
    SMA2: int
        time window in days for longer SMA
    start: str
        start date for data retrieval
    end: str
        end date for data retrieval

    Methods
    =======
    get_data:
        retrieves and prepares the base data set
    set_parameters:
        sets one or two new SMA parameters
    run_strategy:
        runs the backtest for the SMA-based strategy
    plot_results:
        plots the performance of the strategy compared to the symbol
    update_and_run:
        updates SMA parameters and returns the (negative) absolute performance
    optimize_parameters:
        implements a brute force optimization for the two SMA parameters
    '''

    def __init__(self, symbol, SMA1, SMA2, start, end):
        self.symbol = symbol
        self.SMA1 = SMA1
        self.SMA2 = SMA2
        self.start = start
        self.end = end
        self.results = None
        self.get_data()

    def get_data(self):
        ''' Retrieves and prepares the data.
        '''
        raw = pd.read_csv('http://hilpisch.com/pyalgo_eikon_eod_data.csv',
                          index_col=0, parse_dates=True).dropna()
        raw = pd.DataFrame(raw[self.symbol])
        raw = raw.loc[self.start:self.end]
        raw.rename(columns={self.symbol: 'price'}, inplace=True)
        raw['return'] = np.log(raw / raw.shift(1))
        raw['SMA1'] = raw['price'].rolling(self.SMA1).mean()
        raw['SMA2'] = raw['price'].rolling(self.SMA2).mean()
        self.data = raw

    def set_parameters(self, SMA1=None, SMA2=None):
        ''' Updates SMA parameters and resp. time series.
        '''
        if SMA1 is not None:
            self.SMA1 = SMA1
            self.data['SMA1'] = self.data['price'].rolling(
                self.SMA1).mean()
        if SMA2 is not None:
            self.SMA2 = SMA2
            self.data['SMA2'] = self.data['price'].rolling(self.SMA2).mean()

    def run_strategy(self):
        ''' Backtests the trading strategy.
        '''
        data = self.data.copy().dropna()
        data['position'] = np.where(data['SMA1'] > data['SMA2'], 1, -1)
        data['strategy'] = data['position'].shift(1) * data['return']
        data.dropna(inplace=True)
        data['creturns'] = data['return'].cumsum().apply(np.exp)
        data['cstrategy'] = data['strategy'].cumsum().apply(np.exp)
        self.results = data
        # gross performance of the strategy
        aperf = data['cstrategy'].iloc[-1]
        # out-/underperformance of strategy
        operf = aperf - data['creturns'].iloc[-1]
        return round(aperf, 2), round(operf, 2)

    def plot_results(self):
        ''' Plots the cumulative performance of the trading strategy
        compared to the symbol.
        '''
        if self.results is None:
            print('No results to plot yet. Run a strategy.')
        title = '%s | SMA1=%d, SMA2=%d' % (self.symbol,
                                               self.SMA1, self.SMA2)
        self.results[['creturns', 'cstrategy']].plot(title=title,
                                                     figsize=(10, 6))

    def update_and_run(self, SMA):
        ''' Updates SMA parameters and returns negative absolute performance
        (for minimazation algorithm).

        Parameters
        ==========
        SMA: tuple
            SMA parameter tuple
        '''
        self.set_parameters(int(SMA[0]), int(SMA[1]))
        return -self.run_strategy()[0]

    def optimize_parameters(self, SMA1_range, SMA2_range):
        ''' Finds global maximum given the SMA parameter ranges.

        Parameters
        ==========
        SMA1_range, SMA2_range: tuple
            tuples of the form (start, end, step size)
        '''
        opt = brute(self.update_and_run, (SMA1_range, SMA2_range), finish=None)
        return opt, -self.update_and_run(opt)


if __name__ == '__main__':
    smabt = SMAVectorBacktester('EUR=', 42, 252,
                                '2010-1-1', '2020-12-31')
    print(smabt.run_strategy())
    smabt.set_parameters(SMA1=20, SMA2=100)
    print(smabt.run_strategy())
    print(smabt.optimize_parameters((30, 56, 4), (200, 300, 4)))

Clase de Backtesting de Momentum

A continuación se presenta un código Python con una clase para el backtesting vectorizado de estrategias basadas en el momentum de series temporales:

#
# Python Module with Class
# for Vectorized Backtesting
# of Momentum-Based Strategies
#
# Python for Algorithmic Trading
# (c) Dr. Yves J. Hilpisch
# The Python Quants GmbH
#
import numpy as np
import pandas as pd


class MomVectorBacktester(object):
    ''' Class for the vectorized backtesting of
    momentum-based trading strategies.

    Attributes
    ==========
    symbol: str
       RIC (financial instrument) to work with
    start: str
        start date for data selection
    end: str
        end date for data selection
    amount: int, float
        amount to be invested at the beginning
    tc: float
        proportional transaction costs (e.g., 0.5% = 0.005) per trade

    Methods
    =======
    get_data:
        retrieves and prepares the base data set
    run_strategy:
        runs the backtest for the momentum-based strategy
    plot_results:
        plots the performance of the strategy compared to the symbol
    '''

    def __init__(self, symbol, start, end, amount, tc):
        self.symbol = symbol
        self.start = start
        self.end = end
        self.amount = amount
        self.tc = tc
        self.results = None
        self.get_data()

    def get_data(self):
        ''' Retrieves and prepares the data.
        '''
        raw = pd.read_csv('http://hilpisch.com/pyalgo_eikon_eod_data.csv',
                          index_col=0, parse_dates=True).dropna()
        raw = pd.DataFrame(raw[self.symbol])
        raw = raw.loc[self.start:self.end]
        raw.rename(columns={self.symbol: 'price'}, inplace=True)
        raw['return'] = np.log(raw / raw.shift(1))
        self.data = raw

    def run_strategy(self, momentum=1):
        ''' Backtests the trading strategy.
        '''
        self.momentum = momentum
        data = self.data.copy().dropna()
        data['position'] = np.sign(data['return'].rolling(momentum).mean())
        data['strategy'] = data['position'].shift(1) * data['return']
        # determine when a trade takes place
        data.dropna(inplace=True)
        trades = data['position'].diff().fillna(0) != 0
        # subtract transaction costs from return when trade takes place
        data['strategy'][trades] -= self.tc
        data['creturns'] = self.amount * data['return'].cumsum().apply(np.exp)
        data['cstrategy'] = self.amount * \
            data['strategy'].cumsum().apply(np.exp)
        self.results = data
        # absolute performance of the strategy
        aperf = self.results['cstrategy'].iloc[-1]
        # out-/underperformance of strategy
        operf = aperf - self.results['creturns'].iloc[-1]
        return round(aperf, 2), round(operf, 2)

    def plot_results(self):
        ''' Plots the cumulative performance of the trading strategy
        compared to the symbol.
        '''
        if self.results is None:
            print('No results to plot yet. Run a strategy.')
        title = '%s | TC = %.4f' % (self.symbol, self.tc)
        self.results[['creturns', 'cstrategy']].plot(title=title,
                                                     figsize=(10, 6))


if __name__ == '__main__':
    mombt = MomVectorBacktester('XAU=', '2010-1-1', '2020-12-31',
                                10000, 0.0)
    print(mombt.run_strategy())
    print(mombt.run_strategy(momentum=2))
    mombt = MomVectorBacktester('XAU=', '2010-1-1', '2020-12-31',
                                10000, 0.001)
    print(mombt.run_strategy(momentum=2))

Clase de Backtesting de Reversión a la Media

A continuación se presenta un código Python con una clase para el backtesting vectorizado de estrategias basadas en la reversión a la media:.

#
# Python Module with Class
# for Vectorized Backtesting
# of Mean-Reversion Strategies
#
# Python for Algorithmic Trading
# (c) Dr. Yves J. Hilpisch
# The Python Quants GmbH
#
from MomVectorBacktester import *


class MRVectorBacktester(MomVectorBacktester):
    ''' Class for the vectorized backtesting of
    mean reversion-based trading strategies.

    Attributes
    ==========
    symbol: str
        RIC symbol with which to work
    start: str
        start date for data retrieval
    end: str
        end date for data retrieval
    amount: int, float
        amount to be invested at the beginning
    tc: float
        proportional transaction costs (e.g., 0.5% = 0.005) per trade

    Methods
    =======
    get_data:
        retrieves and prepares the base data set
    run_strategy:
        runs the backtest for the mean reversion-based strategy
    plot_results:
        plots the performance of the strategy compared to the symbol
    '''

    def run_strategy(self, SMA, threshold):
        ''' Backtests the trading strategy.
        '''
        data = self.data.copy().dropna()
        data['sma'] = data['price'].rolling(SMA).mean()
        data['distance'] = data['price'] - data['sma']
        data.dropna(inplace=True)
        # sell signals
        data['position'] = np.where(data['distance'] > threshold,
                                    -1, np.nan)
        # buy signals
        data['position'] = np.where(data['distance'] < -threshold,
                                    1, data['position'])
        # crossing of current price and SMA (zero distance)
        data['position'] = np.where(data['distance'] *
                                    data['distance'].shift(1) < 0,
                                    0, data['position'])
        data['position'] = data['position'].ffill().fillna(0)
        data['strategy'] = data['position'].shift(1) * data['return']
        # determine when a trade takes place
        trades = data['position'].diff().fillna(0) != 0
        # subtract transaction costs from return when trade takes place
        data['strategy'][trades] -= self.tc
        data['creturns'] = self.amount * \
            data['return'].cumsum().apply(np.exp)
        data['cstrategy'] = self.amount * \
            data['strategy'].cumsum().apply(np.exp)
        self.results = data
        # absolute performance of the strategy
        aperf = self.results['cstrategy'].iloc[-1]
        # out-/underperformance of strategy
        operf = aperf - self.results['creturns'].iloc[-1]
        return round(aperf, 2), round(operf, 2)


if __name__ == '__main__':
    mrbt = MRVectorBacktester('GDX', '2010-1-1', '2020-12-31',
                              10000, 0.0)
    print(mrbt.run_strategy(SMA=25, threshold=5))
    mrbt = MRVectorBacktester('GDX', '2010-1-1', '2020-12-31',
                              10000, 0.001)
    print(mrbt.run_strategy(SMA=25, threshold=5))
    mrbt = MRVectorBacktester('GLD', '2010-1-1', '2020-12-31',
                              10000, 0.001)
    print(mrbt.run_strategy(SMA=42, threshold=7.5))

1 Fuente: "¿Predice el pasado el futuro?" The Economist, 23 de septiembre de 2009.

2 Para más información sobre los objetos datetime y timedelta, consulta el Apéndice C de Hilpisch (2018).

Get Python para el trading algorítmico now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.