Validación cruzada simétrica combinatoria en MQL5
Introducción
A veces, cuando creamos una estrategia automatizada, comenzamos describiendo reglas basadas en indicadores arbitrarios que deben mejorarse de alguna manera. Este proceso implica la ejecución de varias pruebas con distintos valores de los parámetros de los indicadores seleccionados. De esta forma, podemos encontrar los valores de los indicadores que maximizan el beneficio o cualquier otro indicador que nos interese. El problema aquí es que introducimos un cierto desplazamiento optimista debido al ruido reinante en las series temporales financieras. Este fenómeno se denomina ajuste o sobreajuste (overfitting).
Aunque el ajuste no puede evitarse, el grado de ajuste puede cambiar de una estrategia a otra. Por lo tanto, nos resultaría útil poder determinar su alcance. La Validación cruzada simétrica combinatoria (Combinatorially Symmetrical Cross Validation, CSCV) es un método presentado en el artículo de investigación "The Probability of Backtest Overfitting", por David H. Bailey y otros. La validación puede usarse para evaluar el grado de ajuste al optimizar los parámetros de la estrategia.
En este artículo, demostraremos la implementación de CSCV en MQL5 y mostraremos con un ejemplo cómo se puede aplicar a un asesor.
El método CSCV
En esta sección, describiremos el método CSCV paso a paso, empezando por los aspectos preliminares relativos a los datos que deben recogerse según los criterios de rendimiento seleccionados.
El método CSCV puede aplicarse a diversos ámbitos más allá del desarrollo y el análisis de estrategias, pero en este artículo nos ceñiremos al contexto de la optimización de estrategias. Es decir, tenemos una estrategia definida por un conjunto de parámetros que hay que afinar ejecutando múltiples pruebas con diferentes ajustes de parámetros.
Antes de iniciar cualquier cálculo, tendremos que decidir qué criterios de rendimiento utilizaremos para evaluar la estrategia. El método CSCV es flexible porque puede usarse cualquier medida de rendimiento, desde el simple beneficio hasta medidas basadas en coeficientes.
Los criterios de rendimiento seleccionados también determinarán los datos de referencia que se usarán en los cálculos. Se trata de datos de origen detallados que se recopilarán durante todas las pruebas. Por ejemplo, si decidimos usar el ratio de Sharpe como medida de rendimiento, necesitaríamos obtener el rendimiento por barras cada vez que realicemos la prueba. Si utilizáramos el beneficio simple, necesitaríamos el beneficio o la pérdida en barras. Deberemos comprobar que la cantidad de datos recopilados para cada ejecución sea constante. De este modo, nos aseguramos de disponer de una medida para cada punto de datos relevante en todas las pruebas.
- El primer paso comenzará con la recogida de datos durante la optimización, cuando se prueban distintas opciones de parámetros.
- Una vez finalizada la optimización, combinaremos todos los datos recogidos en las pruebas en una matriz. Cada fila de esta matriz contendrá todos los valores de rendimiento de la barra que se usarán para calcular algunas métricas de rendimiento comercial para la ejecución de prueba correspondiente.
- La matriz tendrá tantas filas como combinaciones de parámetros se hayan probado, mientras que el número de columnas será igual al número de columnas que comprenda todo el periodo de pruebas. A continuación, estas columnas se dividirán en cualquier número par de conjuntos, digamos N conjuntos.
- Estos conjuntos serán submatrices que se utilizarán para formar combinaciones de grupos de tamaño N/2. Así, se crearán un total de N combinaciones tomadas N/2 cada vez, es decir, N C n/2 . Partiendo de cada una de estas combinaciones, crearemos un In-Sample-Set (ISS) combinando las N/2 submatrices, así como el Out-Of-Sample-Set (OOSS) correspondiente a partir de las submatrices restantes no incluidas en el ISS.
- Para cada fila de las matrices ISS y OOSS, calcularemos la métrica de rendimiento correspondiente y nos centraremos en la fila de la matriz ISS con el mejor rendimiento. Esta será la configuración óptima de los parámetros. La fila correspondiente de la matriz OOSS se utilizará para calcular el rango relativo contando el número de pruebas de parámetros fuera de la muestra con un rendimiento inferior al obtenido utilizando la configuración óptima de parámetros, y representando este número como una fracción de todos los conjuntos de parámetros probados.
- Recorriendo todas las combinaciones, acumularemos el número de valores de rango relativo inferiores o iguales a 0,5. Este será el número de configuraciones de parámetros fuera de la muestra cuyo rendimiento resulta inferior al observado utilizando el conjunto óptimo de parámetros. Una vez procesadas todas las combinaciones, este número se presentará como una fracción de todas las combinaciones + 1, lo cual representará la Probabilidad de Sobreajuste del Backtest (PBO).
A continuación se visualizarán los pasos que acabamos de describir cuando N = 4.
En la siguiente sección veremos cómo implementar en código los pasos descritos. Veremos primero el método CSCV principal, y dejaremos el código relacionado con la recogida de datos para el ejemplo que se mostrará al final del artículo.
Implementación de CSCV en MQL5
La clase Ccsvc contenida en CSCV.mqh encapsulará el algoritmo CSCV. CSCV.mqh comenzará incluyendo subfunciones de la biblioteca estándar MQL5 Mathematics.
//+------------------------------------------------------------------+ //| CSCV.mqh | //| Copyright 2023, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2023, MetaQuotes Ltd." #property link "https://www.mql5.com" #include
El puntero de función criterio definirá un tipo de función utilizado para calcular la métrica de rendimiento dado un array como entrada.
#include typedef double (*Criterion)(const double &data[]); // function pointer for performance criterion
Ccscv solo tiene un método con el que los usuarios deben familiarizarse. Podrá llamarse después de inicializar un ejemplar de la clase. Este método CalculateProbabilty() retornará el valor de PBO si tiene éxito. Si se detecta un error, el método retornará -1. A continuación describiremos sus parámetros de entrada:
//+------------------------------------------------------------------+ //| combinatorially symmetric cross validation class | //+------------------------------------------------------------------+ class Cscv { ulong m_perfmeasures; //granular performance measures ulong m_trials; //number of parameter trials ulong m_combinations; //number of combinations ulong m_indices[], //array tracks combinations m_lengths[], //points to number measures for each combination m_flags []; //tracks processing of combinations double m_data [], //intermediary holding performance measures for current trial is_perf [], //in sample performance data oos_perf []; //out of sample performance data public: Cscv(void); //constructor ~Cscv(void); //destructor double CalculateProbability(const ulong blocks, const matrix &in_data,const Criterion criterion, const bool maximize_criterion); };
- El primer parámetro de entrada será blocks. Este se corresponderá con el número de conjuntos (N conjuntos) en que se dividirán las columnas de la matriz.
- in_data es una matriz con un número de filas igual al número total de opciones de parámetros probadas durante la optimización y un número de columnas igual al número de columnas que comprenden toda la historia seleccionada para la optimización.
- criterion — puntero a la función que se utilizará para calcular la métrica de rendimiento seleccionada. La función deberá devolver un valor de tipo double y aceptar una matriz de tipo double como datos de entrada.
- maximize_criterion está relacionado con el criterio en el sentido de que permite especificar si la mejor de las métricas de rendimiento seleccionadas viene determinada por el valor máximo o mínimo. Por ejemplo, si se utilizamos la reducción como criterio de rendimiento, será mejor utilizar el valor más pequeño, por lo que maximise_criterion deberá ser false.
double Cscv::CalculateProbability(const ulong blocks, const matrix &in_data,const Criterion criterion, const bool maximize_criterion) { //---get characteristics of matrix m_perfmeasures = in_data.Cols(); m_trials = in_data.Rows(); m_combinations=blocks/2*2; //---check inputs if(m_combinations<4) m_combinations = 4; //---memory allocation if(ArrayResize(m_indices,int(m_combinations))< int(m_combinations)|| ArrayResize(m_lengths,int(m_combinations))< int(m_combinations)|| ArrayResize(m_flags,int(m_combinations))<int(m_combinations) || ArrayResize(m_data,int(m_perfmeasures))<int(m_perfmeasures) || ArrayResize(is_perf,int(m_trials))<int(m_trials) || ArrayResize(oos_perf,int(m_trials))<int(m_trials)) { Print("Memory allocation error ", GetLastError()); return -1.0; } //---
En ComputeProbability, empezaremos obteniendo el número de columnas y filas de la matriz in_data, y comprobando los bloques para asegurarnos de que es un número par. Obtener el tamaño de la matriz de entrada será necesario para determinar el tamaño de los búferes internos del ejemplar.
int is_best_index ; //row index of oos_best parameter combination double oos_best, rel_rank ; //oos_best performance and relative rank values //--- ulong istart = 0 ; for(ulong i=0 ; i // Block starts here m_lengths[i] = (m_perfmeasures - istart) / (m_combinations-i) ; // It contains this many cases istart += m_lengths[i] ; // Next block } //--- ulong num_less =0; // Will count the number of time OOS of oos_best <= median OOS, for prob for(ulong i=0; i if (i 2 ) // Identify the IS set m_flags[i]=1; else m_flags[i]=0; // corresponding OOS set } //---
Una vez asignada con éxito la memoria para los búferes internos, empezaremos a preparar la partición de las columnas según m_combinations. El array m_indices se rellenará con los índices de columna iniciales para una partición concreta, mientras que m_lengths contendrá el número correspondiente de columnas contenidas en cada una. num_less permitirá contar cuántas veces el rendimiento de la mejor prueba de la muestra resulta inferior al rendimiento de las demás pruebas fuera de la muestra. m_flags será un array entero cuyos valores podrán contener 1 o 0. Esto ayudará a identificar los subconjuntos etiquetados como dentro y fuera de la muestra, a la vez que se enumerarán todas las combinaciones posibles.
ulong ncombo; for(ncombo=0; ; ncombo++) { //--- in sample performance calculated in this loop for(ulong isys=0; isys int n=0; for(ulong ic=0; ic if (m_flags[ic]) { for(ulong i=m_indices[ic]; i //--- out of sample performance calculated here for(ulong isys=0; isys int n=0; for(ulong ic=0; ic if (!m_flags[ic]) { for(ulong i=m_indices[ic]; iEn este punto, se iniciará el ciclo principal, que probará todas las combinaciones de conjuntos dentro y fuera de la muestra. Se usarán dos ciclos internos para calcular el rendimiento modelizado dentro y fuera de la muestra llamando a la función criterion y almacenando este valor en los arrays is_perf y oos_perf, respectivamente.
//--- get the oos_best performing in sample index is_best_index = maximize_criterion?ArrayMaximum(is_perf):ArrayMinimum(is_perf); //--- corresponding oos performance oos_best = oos_perf[is_best_index];El índice del mejor valor de rendimiento en el array is_perf se calculará según maximise_criterion. El correspondiente valor de rendimiento fuera de la muestra se almacenará en la variable oos_best.
//--- count oos results less than oos_best int count=0; for(ulong isys=0; isys if (isys == ulong(is_best_index) || (maximize_criterion && oos_best>=oos_perf[isys]) || (!maximize_criterion && oos_best<=oos_perf[isys])) ++count; }Vamos a recorrer el array oos_perf y a contar cuántas veces el valor oos_best ha sido igual o mejor.
//--- calculate the relative rank rel_rank = double (count)/double (m_trials+1); //--- cumulate num_less if(rel_rank<=0.5) ++num_less;El recuento se utilizará para calcular la clasificación relativa. Por último, num_less se sumará si el rango relativo calculado es inferior a 0,5.
//---move calculation on to new combination updating flags array along the way int n=0; ulong iradix; for(iradix=0; iradix 1 ; iradix++) { if(m_flags[iradix]==1) { ++n; if(m_flags[iradix+1]==0) { m_flags[iradix]=0; m_flags[iradix+1]=0; for(ulong i=0; i if (--n>0) m_flags[i]=1; else m_flags[i]=0; } break; } } }El último ciclo interno se utilizará para pasar a los siguientes conjuntos de datos dentro y fuera de la muestra.
if(iradix == m_combinations-1) { ++ncombo; break; } } //--- final result return double(num_less)/double(ncombo); }
El último bloque if determinará cuándo salir del ciclo externo principal antes de retornar el valor final de PBO dividiendo num_less por ncombo.
Antes de ver un ejemplo de aplicación de la clase Ccscv, deberemos entender lo que este algoritmo dice sobre una estrategia en particular.
Interpretación de los resultados
El algoritmo CSCV que hemos implementado genera una métrica, la PBO. David Bailey y sus coautores señalan que la PBO define la probabilidad de que el conjunto de parámetros que ha ofrecido el mejor rendimiento durante la optimización en un conjunto de datos de muestra logre un rendimiento inferior a la mediana de los resultados de rendimiento utilizando conjuntos de parámetros subóptimos en un conjunto de datos fuera de la muestra.
Cuanto mayor sea el valor, mayor será el grado de ajuste. En otras palabras, existe una alta probabilidad de que una estrategia resulte ineficaz si se aplica fuera de la muestra. La PBO ideal debe ser inferior a 0,1.
El valor de PBO alcanzado dependerá principalmente de la variedad de conjuntos de parámetros probados durante la optimización. Así, deberemos asegurarnos de que los conjuntos de parámetros elegidos sean representativos de los que pueden aplicarse en el mundo real. Incluir intencionadamente combinaciones de parámetros que probablemente no se seleccionarán, o que estén dominadas por combinaciones que se encuentran cerca o lejos de ser óptimas solo distorsionará el resultado final.
Ejemplo
En esta sección, mostraremos la aplicación de la clase Ccscv para el asesor. Modificaremos el asesor Moving Average estándar para incluir el cálculo de PBO. Para aplicar eficazmente el método CSCV, utilizaremos el para recopilar datos de barras. Una vez finalizada la optimización, los datos de cada pasada se recogerán en una matriz. Esto significa que al menos el y el () deberán añadirse al código del asesor. Por último, el asesor seleccionado deberá someterse a una optimización completa utilizando la opción de algoritmo completo lento en el simulador de estrategias.
//+------------------------------------------------------------------+ //| MovingAverage_CSCV_DemoEA.mq5 | //| Copyright 2023, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2023, MetaQuotes Ltd." #property link "https://www.mql5.com" #property version "1.00" #include #include #includeEmpezaremos por incluir los archivos CSCV.mqh y Returns.mqh, que contienen la definición de la clase CReturns. CReturns nos servirá para reunir las rentabilidades de las barras con las que podremos calcular el ratio de Sharpe, la rentabilidad media o la rentabilidad total. Podemos utilizar cualquiera de estos parámetros como criterio para determinar el rendimiento óptimo. Como hemos mencionado al principio de este artículo, la métrica de rendimiento elegida no importa, puedes utilizar cualquier métrica.
sinput uint NumBlocks = 4;
Hemos añadido un nuevo parámetro no optimizable llamado NumBlocks, que determinará el número de particiones que utilizará el algoritmo CSCV. Más adelante veremos que el cambio de este parámetro afectará a la PBO.CReturns colrets; ulong numrows,numcolumns;
Ahora declararemos un ejemplar de CReturns de forma global. Aquí también se declararán numrows y numcolumns, que utilizaremos para inicializar la matriz.
//+------------------------------------------------------------------+ //| TesterInit function | //+------------------------------------------------------------------+ void OnTesterInit() { numrows=1; //--- string name="MaximumRisk"; bool enable; double par1,par1_start,par1_step,par1_stop; ParameterGetRange(name,enable,par1,par1_start,par1_step,par1_stop); if(enable) numrows*=ulong((par1_stop-par1_start)/par1_step)+1; //--- name="DecreaseFactor"; double par2,par2_start,par2_step,par2_stop; ParameterGetRange(name,enable,par2,par2_start,par2_step,par2_stop); if(enable) numrows*=ulong((par2_stop-par2_start)/par2_step)+1; //--- name="MovingPeriod"; long par3,par3_start,par3_step,par3_stop; ParameterGetRange(name,enable,par3,par3_start,par3_step,par3_stop); if(enable) numrows*=ulong((par3_stop-par3_start)/par3_step)+1; //--- name="MovingShift"; long par4,par4_start,par4_step,par4_stop; ParameterGetRange(name,enable,par4,par4_start,par4_step,par4_stop); if(enable) numrows*=ulong((par4_stop-par4_start)/par4_step)+1; }Vamos a añadir el manejador OnTesterInit(), en el que contaremos el número de conjuntos de parámetros a probar.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- colrets.OnNewTick(); //--- if(SelectPosition()) CheckForClose(); else CheckForOpen(); //--- }En el manejador de eventos OnTick(), llamaremos al método OnNewtick() de CReturns.
//+------------------------------------------------------------------+ //| Tester function | //+------------------------------------------------------------------+ double OnTester() { //--- double ret=0.0; double array[]; //--- if(colrets.GetReturns(ENUM_RETURNS_ALL_BARS,array)) { //--- ret = MathSum(array); if(!FrameAdd(IntegerToString(MA_MAGIC),long(MA_MAGIC),double(array.Size()),array)) { Print("Could not add frame ", GetLastError()); return 0; } //--- } //---return return(ret); }Dentro de OnTester(), reuniremos un array de retornos utilizando nuestra ejemplar CReturns declarado globalmente. Por último, añadiremos estos datos al marco usando la llamada FrameAdd().
//+------------------------------------------------------------------+ //| TesterDeinit function | //+------------------------------------------------------------------+ void OnTesterDeinit() { //---prob value numcolumns = 0; double probability=-1; int count_frames=0; matrix data_matrix=matrix::Zeros(numrows,1); vector addvector=vector::Zeros(1); Cscv cscv; //---calculate if(FrameFilter(IntegerToString(MA_MAGIC),long(MA_MAGIC))) { //--- ulong pass; string frame_name; long frame_id; double passed_value; double passed_data[]; //--- while(FrameNext(pass,frame_name,frame_id,passed_value,passed_data)) { //--- if(!numcolumns) { numcolumns=ulong(passed_value); addvector.Resize(numcolumns); data_matrix.Resize(numrows,numcolumns); } //--- if(addvector.Assign(passed_data)) { data_matrix.Row(addvector,pass); count_frames++; } //--- } } else Print("Error retrieving frames ", GetLastError()); //---results probability = cscv.CalculateProbability(NumBlocks,data_matrix,MathSum,true); //---output results Print("cols ",data_matrix.Cols()," rows ",data_matrix.Rows()); Print("Number of passes processed: ", count_frames, " Probability: ",probability); //--- }Es precisamente en OnTesterDeinit() donde encontraremos la mayor parte de las adiciones realizadas al asesor. Aquí declararemos un ejemplar de Ccscv junto con las variables de tipo matriz y vector. Luego recorreremos en un ciclo todos los marcos y transmitiremos sus datos a la matriz. El vector se utilizará como intermediario para añadir una nueva fila de datos para cada marco.
Antes de mostrar los resultados en la pestaña Expertos del terminal, llamaremos al método CalculateProbability() de Ccscv. En este ejemplo, hemos transmitido MathSum() al método, lo cual significa que los ingresos totales se utilizarán para determinar el conjunto óptimo de parámetros. La salida también ofrecerá una indicación del número de marcos procesados para confirmar que se han capturado todos los datos.
Aquí están algunos resultados de la ejecución de nuestro asesor modificado con diferentes configuraciones en marcos temporales distintos. El resultado de la PBO se muestra en la pestaña Expertos del terminal.MovingAverage_CSCV_DemoEA (EURUSD,H1) Number of passes processed: 23520 Probability: 0.3333333333333333
NumBlocks | Marco temporal | Probabilidad de ajuste de las pruebas de la historia (PBO) |
---|---|---|
4 | W1 | 0,3333 |
4 | D1 | 0,6666 |
4 | H12 | 0,6666 |
8 | W1 | 0,2 |
8 | D1 | 0,8 |
8 | H12 | 0,6 |
16 | W1 | 0,4444 |
16 | D1 | 0,8888 |
16 | H12 | 0,6666 |
El mejor resultado que hemos obtenido es 0,2. Los otros han sido mucho peores. Esto indica una alta probabilidad de que el asesor muestre un rendimiento pobre cuando se aplique a cualquier conjunto de datos fuera de la muestra. También veremos que los malos resultados de la PBO persistirán en distintos marcos temporales. La modificación del número de secciones utilizadas en el análisis no ha mejorado la mala puntuación inicial.
Conclusión
Hoy hemos mostrado la aplicación del método de validación cruzada simétrica combinatoria para evaluar el ajuste después de un procedimiento de optimización. Comparado con el uso de permutaciones de Monte Carlo para cuantificar el ajuste, la CSCV es relativamente rápida. El método también hace un uso eficiente de los datos históricos disponibles. No obstante, existen escollos potenciales que debemos tener en cuenta. La fiabilidad de este método dependerá exclusivamente de los datos utilizados.
En particular, se comprobará el grado de variación de los parámetros. Utilizar menos variaciones de los parámetros podría llevar a subestimar el ajuste. Al mismo tiempo, la inclusión de un gran número de combinaciones de parámetros poco realistas podría dar lugar a sobreestimaciones. También debemos considerar el plazo elegido para el periodo de optimización. Esto puede afectar a la elección de los parámetros aplicados a la estrategia. Se sobreentiende que el valor final de la PBO puede variar de vez en cuando. Al realizar las pruebas, deberemos considerar el mayor número posible de configuraciones de parámetros.
Una desventaja notable de esta prueba es que no puede aplicarse fácilmente a los asesores cuyo código fuente no esté disponible. En teoría, se podrían realizar backtests independientes para cada posible configuración de los parámetros, pero esto requeriría tanto trabajo como utilizar los métodos de Monte Carlo.
En el artículo original figura una descripción detallada de la CSCV y la interpretación de la PBO. Encontrará el enlace en el segundo párrafo de este artículo. A continuación se adjunta el código fuente de todos los programas mencionados en el artículo.
Nombre del fichero | Descripción |
---|---|
Mql5\Include\Returns.mqh | Define una clase CReturns para recopilar los datos de rentabilidad o equidad en tiempo real. |
Mql5\Include\CSCV.mqh | Contiene la definición de la clase Ccscv, que implementa la validación cruzada simétrica combinatoria. |
Mql5\Experts\MovingAverage_CSCV_DemoEA.mq5 | Asesor Moving Average modificado que demuestra la aplicación de la clase Ccscv. |
Traducción del inglés realizada por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/en/articles/13743
- Aplicaciones de trading gratuitas
- 8 000+ señales para copiar
- Noticias económicas para analizar los mercados financieros
Usted acepta la política del sitio web y las condiciones de uso