Validação cruzada combinatoriamente simétrica no MQL5
Introdução
Às vezes, ao criar uma estratégia automatizada, começamos descrevendo regras baseadas em indicadores arbitrários que precisam ser de alguma forma refinados. Este processo envolve vários testes com diferentes parâmetros para os indicadores selecionados. Dessa forma, podemos encontrar quer sejam valores de indicador que maximizam o lucro ou um valor de interesse. O problema aqui é que introduzimos uma certa dose de viés otimista devido ao ruído predominante nas séries temporais financeiras. Este fenômeno é chamado de sobreajuste, ou overfitting.
Embora o overfitting não possa ser completamente evitado, seu grau de manifestação pode variar de uma estratégia para outra. Por esse motivo, seria útil poder determinar seu grau. A verificação cruzada combinatória simétrica (combinatorially symmetric cross-validation, CSCV) é um método apresentado no artigo científico "The Probability of Backtest Overfitting" de David H. Bailey et al. Esta verificação pode ser usada para avaliar o grau de ajuste durante a otimização dos parâmetros de uma estratégia.
Neste artigo, demonstraremos a implementação do CSCV no MQL5 e, através de um exemplo, mostraremos como ele pode ser aplicado a um EA.
Método CSCV
Nesta seção, descreveremos passo a passo o CSCV, começando pelos aspectos preliminares relacionados aos dados que precisam ser coletados de acordo com os critérios de desempenho selecionados.
O método CSCV pode ser aplicado em várias áreas além do desenvolvimento e análise de estratégias, mas neste artigo, nos concentramos no contexto da otimização de estratégias. Ou seja, temos uma estratégia definida por um conjunto de parâmetros que precisam ser ajustados com precisão por meio de numerosos testes com diferentes configurações de parâmetros.
Antes de começarmos qualquer cálculo, primeiro precisamos decidir quais critérios de desempenho usaremos para avaliar a estratégia. O método CSCV é flexível, pois permite o uso de qualquer medição de desempenho, desde lucro simples até medidas baseadas em coeficientes.
Os critérios de desempenho selecionados também definirão os dados brutos que serão usados nos cálculos. Estes são dados detalhados não processados que serão coletados durante todas as execuções de teste. Por exemplo, se decidirmos usar o índice de Sharpe como uma medida de desempenho, precisaremos obter valores de retorno por barra a cada teste executado. Se usássemos lucro simples, precisaríamos de lucro ou perda por barras. É necessário garantir que o volume de dados coletados para cada execução seja consistente. Assim, garantimos a existência de uma medida para cada ponto de dados correspondente em todos os testes executados.
- O primeiro passo começa com a coleta de dados durante a otimização, quando diferentes opções de parâmetros são testadas.
- Após a otimização, combinamos todos os dados coletados durante os testes em uma matriz. Cada linha desta matriz conterá todos os valores de desempenho por barra que serão usados para medir o desempenho do trading para o teste correspondente.
- A matriz terá tantas linhas quanto as combinações de parâmetros testadas, e o número de colunas será igual ao número de colunas que compõem todo o período de teste. Essas colunas são então divididas em um número arbitrário de conjuntos pares. Digamos que sejam N conjuntos.
- Esses conjuntos são submatrizes que serão usadas para formar combinações de grupos de tamanho N/2. É criado um total de N combinações, tomado N/2 de cada vez, ou seja, N C n/2 . De cada uma dessas combinações, criamos um conjunto na amostra (In-Sample-Set, ISS), combinando submatrizes N/2, bem como um conjunto correspondente fora da amostra (Out-Of-Sample-Set, OOSS) das submatrizes restantes não incluídas no ISS.
- Para cada linha das matrizes ISS e OOSS, calculamos a métrica de desempenho correspondente, e observamos a linha na matriz ISS com os melhores valores. Esta é a configuração ótima de parâmetros. A linha correspondente na matriz OOSS é usada para calcular o posto relativo, contando o número de testes de parâmetros fora da amostra com desempenho inferior em comparação com aquele alcançado usando a configuração de parâmetros ótima, e apresentando esse número como uma fração de todos os conjuntos de parâmetros testados.
- Ao passar por todas as combinações, acumulamos o número de valores de posto relativo que são menores ou iguais a 0,5. Este é o número de configurações de parâmetros fora da amostra cujo desempenho é inferior ao observado ao usar o conjunto de parâmetros ótimo. Após processar todas as combinações, esse número é apresentado como uma fração de todas as combinações + 1, representando a probabilidade de superajuste do teste retrospectivo (Probability of Backtest Overfitting, PBO).
Abaixo está a representação dos passos descritos com N = 4.
Na próxima seção, consideraremos como podemos implementar os passos descritos no código. Primeiramente, consideraremos o método principal CSCV, e o código relacionado à coleta de dados será deixado para um exemplo que será demonstrado no final do artigo.
Implementação do CSCV no MQL5
A classe Ccsvc, contida em CSCV.mqh, encapsula o algoritmo CSCV. CSCV.mqh começa com a inclusão de subfunções da biblioteca padrão 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 <Math\Stat\Math.mqh>
O ponteiro da função Criterion define o tipo de função para calcular o desempenho usando um array como parâmetro de entrada.
#include <Math\Stat\Math.mqh> typedef double (*Criterion)(const double &data[]); // function pointer for performance criterion
Na Ccscv, existe apenas um método que os usuários precisam conhecer. Ele pode ser chamado após a inicialização de uma instância da classe. Este método CalculateProbabilty() retorna o valor do PBO em caso de sucesso. Se um erro for encontrado, o método retorna -1. Abaixo está a descrição de seus 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); };
- blocks é primeiro parâmetro de entrada. Ele corresponde ao número de conjuntos (N conjuntos) em que as colunas da matriz serão divididas.
- in_data é a matriz com o número de linhas igual ao número total de variantes de parâmetros testadas durante a otimização, e o número de colunas igual ao número de colunas que compõem todo o histórico selecionado para otimização.
- criterion é um ponteiro para a função que será usada para calcular a medida do desempenho selecionada. A função deve retornar um valor do tipo double e aceitar um array do tipo double como dados de entrada.
- maximize_criterion está relacionado a criterion, pois permite especificar se a melhor das medidas de desempenho selecionadas é determinada pelo valor máximo ou mínimo. Por exemplo, se usar rebaixamento como critério de desempenho, é melhor usar o menor valor, logo maximize_criterion deve 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; } //---
Em ComputeProbability, começamos obtendo o número de colunas e linhas da matriz in_data, além de verificar blocks para garantir que seja um número par. Obter as dimensões da matriz de entrada é necessário para determinar o tamanho dos buffers internos da instância.
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<m_combinations ; i++) { m_indices[i] = istart ; // 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<m_combinations; i++) { if(i<m_combinations/2) // Identify the IS set m_flags[i]=1; else m_flags[i]=0; // corresponding OOS set } //---
Assim que a memória é alocada com sucesso para os buffers internos, começamos a nos preparar para dividir as colunas de acordo com m_combinations. O array m_indices é preenchido com os índices das colunas iniciais para uma seção específica, e m_lengths conterá o número correspondente de colunas contidas em cada uma delas. num_less mantém a contagem de quantas vezes o desempenho do melhor teste na amostra é menor do que o desempenho dos outros testes fora da amostra. m_flags é um array de inteiros, cujos valores podem conter 1 ou 0. Isso ajuda a identificar subconjuntos marcados como incluídos e não incluídos na amostra, ao iterar todas as combinações possíveis.
ulong ncombo; for(ncombo=0; ; ncombo++) { //--- in sample performance calculated in this loop for(ulong isys=0; isys<m_trials; isys++) { int n=0; for(ulong ic=0; ic<m_combinations; ic++) { if(m_flags[ic]) { for(ulong i=m_indices[ic]; i<m_indices[ic]+m_lengths[ic]; i++) m_data[n++] = in_data.Flat(isys*m_perfmeasures+i); } } is_perf[isys]=criterion(m_data); } //--- out of sample performance calculated here for(ulong isys=0; isys<m_trials; isys++) { int n=0; for(ulong ic=0; ic<m_combinations; ic++) { if(!m_flags[ic]) { for(ulong i=m_indices[ic]; i<m_indices[ic]+m_lengths[ic]; i++) m_data[n++] = in_data.Flat(isys*m_perfmeasures+i); } } oos_perf[isys]=criterion(m_data); }
Neste ponto, começa o loop principal, que itera todas as combinações de conjuntos na amostra e fora da amostra. Dois loops internos são usados para calcular o desempenho simulado na amostra e fora da amostra, chamando a função criterion e salvando esse valor nos arrays is_perf e 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];
O índice do melhor valor de desempenho no array is_perf é calculado de acordo com maximize_criterion. O valor correspondente de desempenho fora da amostra é salvo na variável oos_best.
//--- count oos results less than oos_best int count=0; for(ulong isys=0; isys<m_trials; isys++) { if(isys == ulong(is_best_index) || (maximize_criterion && oos_best>=oos_perf[isys]) || (!maximize_criterion && oos_best<=oos_perf[isys])) ++count; }
Vamos percorrer o array oos_perf e contar quantas vezes o valor de oos_best foi igual ou melhor.
//--- calculate the relative rank rel_rank = double (count)/double (m_trials+1); //--- cumulate num_less if(rel_rank<=0.5) ++num_less;
A contagem é usada para calcular o posto relativo. Finalmente, num_less é somado, se o posto relativo calculado for menor que 0,5.
//---move calculation on to new combination updating flags array along the way int n=0; ulong iradix; for(iradix=0; iradix<m_combinations-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<iradix; i++) { if(--n>0) m_flags[i]=1; else m_flags[i]=0; } break; } } }
O último loop interno é usado para passar para os próximos conjuntos de dados na amostra e fora da amostra.
if(iradix == m_combinations-1) { ++ncombo; break; } } //--- final result return double(num_less)/double(ncombo); }
O último bloco if determina quando sair do loop externo principal antes de retornar o valor final do PBO, dividindo num_less por ncombo.
Antes de considerarmos um exemplo de aplicação da classe Ccscv, precisamos entender o que este algoritmo diz sobre uma estratégia específica.
Interpretação dos resultados
O algoritmo CSCV que implementamos produz uma métrica, o PBO. David Bailey e coautores observam que o PBO define a probabilidade de que um conjunto de parâmetros que proporcionou o melhor desempenho durante a otimização no conjunto de dados na amostra atinja um desempenho abaixo da mediana dos resultados de desempenho usando conjuntos de parâmetros não ótimos no conjunto de dados fora da amostra.
Quanto maior esse valor, maior é o grau de ajuste. Em outras palavras, há uma maior probabilidade de que a estratégia seja ineficaz se aplicada fora da amostra. Um PBO ideal deve ser inferior a 0,1.
O valor alcançado do PBO dependerá principalmente da diversidade dos conjuntos de parâmetros testados durante a otimização. É importante garantir que os conjuntos de parâmetros selecionados sejam representativos dos que podem ser aplicados em condições reais. A inclusão intencional de combinações de parâmetros que são improváveis de serem escolhidas ou que dominem combinações próximas ou distantes do ótimo apenas distorcerá o resultado final.
Exemplo
Nesta seção, mostraremos a aplicação da classe Ccscv para um EA. Vamos modificar o EA padrão Moving Average, incluindo nele o cálculo do PBO. Para uma implementação eficaz do método CSCV, usaremos frames para coletar dados por barras. Após a otimização, os dados de cada execução serão coletados em uma matriz. Isso significa que pelo menos os manipuladores de eventos OnTester e OnTesterDeinit() precisam ser adicionados ao código do EA. Finalmente, o EA selecionado deve ser submetido a uma otimização completa usando a opção de algoritmo completo lento no testador de estratégias.
//+------------------------------------------------------------------+ //| 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 <Returns.mqh> #include <CSCV.mqh> #include <Trade\Trade.mqh>
Começaremos incluindo os arquivos CSCV.mqh e Returns.mqh, que contêm a definição da classe CReturns. CReturns será útil para coletar retornos por barras, com os quais podemos calcular o índice de Sharpe, o retorno médio ou o retorno total. Podemos usar qualquer um desses parâmetros como critério para determinar o desempenho ótimo. Como mencionado no início do artigo, a métrica de desempenho escolhida não importa; qualquer uma pode ser usada.
sinput uint NumBlocks = 4;
Foi adicionado um novo parâmetro não otimizável chamado NumBlocks, que determina o número de seções que serão usadas pelo algoritmo CSCV. Mais tarde veremos como alterar esse parâmetro afetará o PBO.
CReturns colrets;
ulong numrows,numcolumns;
Uma instância de CReturns é declarada globalmente. Aqui também são declaradas numrows e numcolumns, que usaremos para inicializar a 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; }
Adicionaremos o manipulador OnTesterInit(), onde contamos o número de conjuntos de parâmetros testados.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- colrets.OnNewTick(); //--- if(SelectPosition()) CheckForClose(); else CheckForOpen(); //--- }
No manipulador de eventos OnTick(), chamamos o 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(), coletamos um array de retornos usando nossa instância globalmente declarada de CReturns. E, finalmente, adicionamos esses dados ao frame usando a chamada 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); //--- }
É em OnTesterDeinit() que encontramos a maior parte das adições feitas ao EA. Aqui declaramos uma instância de Ccscv junto com variáveis do tipo matriz e vetor. Iteramos por todos os frames e passamos seus dados para a matriz. O vetor é usado como um intermediário para adicionar uma nova linha de dados para cada frame.
O método CalculateProbability() do Ccscv é chamado antes de exibir os resultados na aba "Experts" do terminal. Neste exemplo, passamos a função MathSum() para o método, o que significa que o retorno total é usado para determinar o conjunto ótimo de parâmetros. A saída também fornece uma visão geral do número de frames processados para confirmar que todos os dados foram capturados.
Aqui estão alguns resultados do nosso EA modificado com diferentes configurações em várias escalas de tempo. O resultado do PBO é exibido na aba "Experts" do terminal.
MovingAverage_CSCV_DemoEA (EURUSD,H1) Number of passes processed: 23520 Probability: 0.3333333333333333
NumBlocks | Timeframe | Probabilidade de Overfitting no Backtest (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 |
O melhor resultado que obtivemos foi 0,2. Os outros foram significativamente piores. Isso indica uma alta probabilidade de que o EA exibirá um fraco desempenho quando aplicado a qualquer conjunto de dados fora da amostra. Também vemos que os maus resultados do PBO persistem em diferentes escalas de tempo. Alterar o número de seções usadas na análise não melhorou a avaliação inicialmente pobre.
Considerações finais
Demonstramos como implementar o método de verificação cruzada combinatoriamente simétrica para avaliar o ajuste após o procedimento de otimização. Comparado ao uso de permutações de Monte Carlo para avaliação quantitativa do ajuste, o CSCV é relativamente mais rápido. O método também permite o uso eficiente dos dados históricos disponíveis. No entanto, existem potenciais armadilhas das quais se deve estar ciente. O fato de confiarmos ou não neste método depende exclusivamente dos dados utilizados.
Em particular, foi testado o grau de variação dos parâmetros. Usar menos variações de parâmetros pode levar a uma subestimação do ajuste. Por outro lado, incluir um grande número de combinações de parâmetros irrealistas pode levar a estimativas exageradas. A escala de tempo escolhida para o período de otimização pode afetar a escolha dos parâmetros aplicados à estratégia. Isso implica que o valor final do PBO pode variar em diferentes escalas de tempo. Ao testar, devemos considerar o maior número possível de variações de parâmetros.
Um dos maiores defeitos deste teste é que ele não é facilmente aplicável a EAs cujo código fonte não está disponível. Teoricamente, seria possível realizar backtests separados para cada possível variação de parâmetros, mas isso seria tão trabalhoso quanto usar métodos de Monte Carlo.
Uma descrição detalhada do CSCV e a interpretação do PBO podem ser encontradas no artigo original. O link é fornecido no segundo parágrafo deste artigo. O código-fonte de todos os programas mencionados no artigo é fornecido abaixo.
Nome do arquivo | Descrição |
---|---|
Mql5\Include\Returns.mqh | Define a classe CReturns para coletar dados de retorno ou equity em tempo real. |
Mql5\Include\CSCV.mqh | Contém a definição da classe Ccscv, que implementa a verificação cruzada combinatória simétrica. |
Mql5\Experts\MovingAverage_CSCV_DemoEA.mq5 | EA Moving Average modificado, demonstrando a aplicação da classe Ccscv. |
Traduzido do Inglês pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/en/articles/13743
- Aplicativos de negociação gratuitos
- 8 000+ sinais para cópia
- Notícias econômicas para análise dos mercados financeiros
Você concorda com a política do site e com os termos de uso