Redes neuronales en el trading: Pipeline inteligente de predicciones (Final)
Introducción
El framework Time-MoE ofrece una perspectiva verdaderamente nueva sobre cómo trabajar con series temporales. A diferencia de los modelos clásicos, este conserva toda la información sobre cada tick o vela gracias a la tokenización puntual, y luego enriquece estos tokens atómicos mediante la incorporación SwiGLU, que puede capturar tanto movimientos de tendencia suaves como picos bruscos de volatilidad.
La idea clave de los autores consiste en una mezcla de expertos dispersa dentro del Decoder-Only Transformer, donde los modelos más relevantes se seleccionan dinámicamente para cada token y, junto con un experto común persistente, forman una arquitectura verdaderamente adaptativa y escalable. Las múltiples cabezas de predicción completan el panorama, lo cual permite generar estimaciones simultáneamente en diferentes horizontes, desde el último tick hasta la tendencia semanal.

En el primer artículo dedicado al framework Time-MoE, explicamos paso a paso cómo convertir las áridas ideas del artículo: "Time-MoE: Billion-Scale Time Series Foundation Models with Mixture of Experts" en código MQL5 de trabajo. Hemos creado el módulo CNeuronSwiGLUOCL, que transforma los datos brutos en vectores latentes usando la mezcla de dos proyecciones.
En la segunda parte, nos centramos en el mecanismo de mezcla de expertos dispersa y lo ensamblamos en un único módulo, CNeuronTimeMoESparseExperts, donde combinamos rutas de procesamiento de datos individuales y comunes, un enrutador Top-K y una puerta sigmoide del experto común.
Ahora es el momento de conectar todos estos elementos en un modelo completo y organizar su formación. Finalmente, probaremos el modelo entrenado con datos históricos reales.
Módulo de atención
Antes de comenzar a construir la arquitectura completa del modelo, debemos realizar un ajuste pequeño pero crucial: reemplazar el conocido bloque FeedForward en la arquitectura Transformer con el módulo de mezcla experta dispersa que hemos creado. Recordemos que Time-MoE se basa en la arquitectura Decoder-Only, en la que la atención cruzada (Cross‑Attention) juega un papel clave, permitiendo que el decodificador tenga en cuenta la información de la capa de contexto.
Para ello, se elige como clase base un componente de atención cruzada ya preparado, CNeuronCrossDMHAttention, que ofrece todos los mecanismos necesarios. Nuestra tarea consiste simplemente en reemplazar el bloque FeedForward con una llamada a CNeuronTimeMoESparseExperts. La estructura del nuevo objeto CNeuronTimeMoEAttention se muestra a continuación.
class CNeuronTimeMoEAttention : public CNeuronCrossDMHAttention { protected: //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override { return feedForward(NeuronOCL); } virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override { return calcInputGradients(NeuronOCL); } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override { return updateInputWeights(NeuronOCL); } public: CNeuronTimeMoEAttention(void) {}; ~CNeuronTimeMoEAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint window_cross, uint units_cross, uint heads, uint layers, uint experts, uint experts_dimension, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronTimeMoEAttention; } };
Conviene recordar que el objeto CNeuronCrossDMHAttention se basa en el principio de arquitectura interna dinámica. Y esto significa que los componentes clave del módulo no se declaran de forma rígida en la estructura de la clase, sino que se crean sobre la marcha en la cantidad necesaria durante la inicialización. En la estructura de clases, solo se crea un array dinámico para almacenar punteros a estos objetos, lo cual permite, si es necesario, rediseñar literalmente el circuito interno sin editar todo el código: basta con ajustar la lógica en el método Init.
Los métodos restantes simplemente se heredan de la clase padre o necesitan cambios mínimos. Este enfoque ofrece la máxima flexibilidad y reutilización, lo que permite crear fácilmente nuevas variaciones del módulo cambiando únicamente los parámetros de inicialización. Por eso centramos toda nuestra atención en el método de inicialización: aquí es donde nace la estructura interna de nuestro módulo de atención con el bloque MoE habilitado.
bool CNeuronTimeMoEAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint window_cross, uint units_cross, uint heads, uint layers, uint experts, uint experts_dimension, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
En el cuerpo del método, primero llamamos al método homónimo de la capa neuronal básica. Esto permite crear la estructura básica del módulo. Y entonces comienza la parte más interesante. Preparamos un array de capas internas cLayers y lo vinculamos a nuestra interfaz OpenCL.
cLayers.Clear(); cLayers.SetOpenCL(OpenCL); CNeuronRelativeSelfAttention *attention = NULL; CNeuronRelativeCrossAttention *cross = NULL; CNeuronTimeMoESparseExperts *MoE = NULL; bool use_self = units_count > 0; int layer = 0;
Y luego preparamos las variables locales requeridas. Con esto finaliza la fase preparatoria.
Luego, en un ciclo de un número determinado de capas internas, vamos construyendo paulatinamente una sola cadena. Si hay más de un token en el flujo de información principal, primero crearemos e inicializaremos el módulo Self-Attention, agregándolo a cLayers. Esto permite que el decodificador examine por sí mismo los tokens ya generados antes de recurrir a fuentes de contexto externas.
for(uint i = 0; i < layers; i++) { if(use_self) { attention = new CNeuronRelativeSelfAttention(); if(!attention || !attention.Init(0, layer, OpenCL, window, window_key, units_count, heads, optimization, iBatch) || !cLayers.Add(attention) ) { delete attention; return false; } layer++; }
A continuación, sin más dilación, se crea un módulo de atención cruzada que combina la información de la capa anterior con el contexto. Luego pasamos sobre la marcha los parámetros de las ventanas y el número de cabezas, y este se pone en marcha inmediatamente, preparado para calcular consultas, claves y valores. Dentro del ciclo, se guarda un puntero a cada uno de estos objetos en un array de capas, como si estuviéramos ensamblando una única cadena de procesamiento a partir de enlaces individuales.
cross = new CNeuronRelativeCrossAttention(); if(!cross || !cross.Init(0, layer, OpenCL, window, window_key, units_count, heads, window_cross, units_cross, optimization, iBatch) || !cLayers.Add(cross) ) { delete cross; return false; } layer++;
Pero lo realmente sorprendente viene justo después de Cross-Attention: creamos una instancia de CNeuronTimeMoESparseExperts. Aquí es donde se configura nuestro bloque MoE : indicamos el número de expertos, el tamaño de sus proyecciones (es decir, cuántas características procesa cada experto) y el parámetro topK, que determina cuántos expertos se activan en cada pasada. Y, por supuesto, no nos olvidaremos de la conexión con el contexto de OpenCL.
MoE = new CNeuronTimeMoESparseExperts(); if(!MoE || !MoE.Init(0, layer, OpenCL, window, experts_dimension, units_count, 1, experts, topK, optimization, iBatch) || !cLayers.Add(MoE) ) { delete MoE; return false; } layer++; }
Una vez finalizado el ciclo de inicialización de todas las capas, conectaremos los búferes de resultados de la última capa a las interfaces externas del objeto.
SetOutput(MoE.getOutput(), true); SetGradient(MoE.getGradient(), true); //--- return true; }
Como resultado, el método Init transformará un contenedor vacío en un organismo vivo de tres tipos de capas dispuestas en una secuencia estricta, y lo hará sin modificar en absoluto los mecanismos básicos de atención. Todas las dinámicas se crean únicamente mediante la adición de nuevos elementos y su configuración paramétrica: este es un ejemplo clásico de arquitectura flexible con un mínimo esfuerzo de mantenimiento.
No obstante, deberemos considerar un pequeño detalle: El módulo de atención cruzada requiere dos flujos de información: uno principal y otro de contexto. Pero en el framework de Time-MoE vemos solo uno. Este problema se resuelve con una intervención mínima en los mecanismos básicos. Simplemente estamos redefiniendo los métodos de pasada directa y inversa, que, dado un flujo de entrada, lo redirigen en dos direcciones.
bool CNeuronTimeMoEAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; return CNeuronCrossDMHAttention::feedForward(NeuronOCL, NeuronOCL.getOutput()); }
A primera vista, dicha operación convierte el módulo Cross-Attention en Self-Attention. Pero nuestra idea consiste en transmitir únicamente los tokens del último paso de tiempo a través del flujo de información principal. Esto se soluciona reduciendo el número de elementos analizados en el flujo de información principal. Y a través del flujo de información contextual, transmitimos un conjunto completo de información, lo cual nos permite enriquecer los tokens del flujo de información principal con todo el contexto histórico. Esto conserva todas las ventajas de la arquitectura Time-MoE basada únicamente en decodificadores: cada nuevo token consulta a expertos en su subespacio específico y, simultáneamente, aprovecha toda la dinámica de mercado acumulada.
El código completo de esta clase y todos sus métodos se ofrece en el archivo adjunto.
Arquitectura de los modelos
Una vez implementados todos los componentes necesarios, podemos construir finalmente la arquitectura completa de los modelos entrenables. Al igual que en artículos anteriores, el proyecto se basa en el framework Actor-Director-Critic, que ha demostrado su eficacia en tareas de aprendizaje por refuerzo. Es en este marco donde integramos todos los desarrollos relacionados con Time-MoE, implementándolos en el Environmental State Encoder, la parte del modelo responsable de generar una representación semánticamente rica de los datos de origen.
Sin embargo, en esta etapa surge un punto a considerar. El proceso general que usamos proporciona una única salida para el modelo: un vector de características universal utilizado por todos los componentes: El Actor, el Director y el Crítico. Al mismo tiempo, la arquitectura propietaria de Time-MoE presupone la presencia de varias cabezas de predicción, cada uno de los cuales opera con un horizonte de planificación diferente. Y esto crea un posible conflicto. Mezclar todas las salidas en un solo búfer complica el entrenamiento y dificulta la interpretación.
Decidimos no optar por la vía de combinarlo todo en una sola estructura. En cambio, hemos implementado una clara separación lógica entre el Codificador y las cabezas de predicción. El codificador (Time-MoE) actúa como un mecanismo universal de extracción de características: funciona igual para todos. Luego, para cada horizonte de planificación, se crea su propio modelo, que recibe como entrada los resultados del trabajo del Codificador y elabora una previsión dentro del marco de su tarea.
Este enfoque ofrece beneficios tangibles: mantenemos una única unidad de aprendizaje para la extracción de información, pero al mismo tiempo garantizamos la independencia y la flexibilidad a nivel de pronóstico. Cada cabeza opera en su propio horizonte temporal, lo cual significa que puede adaptarse a la naturaleza correspondiente de las fluctuaciones del mercado. Como resultado, la arquitectura sigue siendo modular, escalable y, al mismo tiempo, profundamente adaptable a la estructura del contexto temporal.
Tras definir la lógica general del framework, pasaremos a construir la arquitectura de los modelos entrenados, en la que cada subsistema se ensambla según un único principio. Esto garantiza la claridad estructural y permite que el modelo se adapte de forma flexible a las necesidades del núcleo de operaciones.
Toda la inicialización se concentra en el método CreateDescriptions, que ensambla las arquitecturas capa a capa, comenzando con el Codificador.
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&forecast1, CArrayObj *&forecast2, CArrayObj *&forecast3, CArrayObj *&actor, CArrayObj *&director, CArrayObj *&critic ) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!forecast1) { forecast1 = new CArrayObj(); if(!forecast1) return false; } if(!forecast2) { forecast2 = new CArrayObj(); if(!forecast2) return false; } if(!forecast3) { forecast3 = new CArrayObj(); if(!forecast3) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!director) { director = new CArrayObj(); if(!director) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
Los datos históricos brutos del mercado se introducen en la entrada del Codificador. La primera capa es simplemente un objeto básico, que es esencialmente una interfaz para recibir datos. A continuación, se aplica una capa de normalización por lotes con ruido añadido que estabiliza la distribución de los valores originales e introduce cierto aumento de datos, lo que ayuda a evitar el sobreajuste del modelo.
//--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; uint prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormWithNoise; descr.count = prev_count; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
A continuación, se utiliza una capa ConcatDiff, que añade canales de valores diferenciales, dividiendo cada barra en sus partes constituyentes y preparando la estructura de datos para un procesamiento más complejo.
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatDiff; prev_count = descr.count = HistoryBars; descr.layers = BarDescr; descr.step = 1; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
Después de ello, los datos se envían al módulo Mamba4CastEmbedding. Aquí es donde la serie de entrada se enriquece con etiquetas de varias escalas temporales simultáneamente. Este mecanismo nos permite obtener una representación multifrecuencia de la señal original. Esta capa produce una ventana latente de longitud fija (NSkills), que luego se pasa a través de una capa de transposición para cambiar la dimensionalidad del tensor cómoda para el procesamiento posterior de secuencias unitarias independientes de canales individuales.
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defMamba4CastEmbeding; prev_count = descr.count = HistoryBars; descr.window = 2 * BarDescr; uint prev_out = descr.window_out = NSkills; { uint temp[] = {PeriodSeconds(PERIOD_H1), PeriodSeconds(PERIOD_D1)}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = descr.count;
A continuación, se añade una capa de incorporación de tipo SwiGLU. Esta aumenta la profundidad de las características y constituye la base inicial para una representación abstracta del estado.
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSwiGLUOCL; descr.count = Segments; descr.window = (prev_out + Segments - 1) / Segments; descr.variables = prev_count; prev_out = descr.window_out = EmbeddingSize; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_count = descr.count; uint prev_var = descr.variables;
A continuación, se aplica una capa de transposición, TransposeRCDOCL, que permite reorientar las dimensionalidades de los pasos de tiempo y los canales analizados. Esta operación resulta fundamental para el correcto funcionamiento del módulo de atención creado. Gracias a ello, los tokens del último valor temporal de todas las variables analizadas se recopilan al principio del búfer.
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeRCDOCL; descr.count = prev_var; descr.window = prev_count; descr.step = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count * prev_out * prev_var; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
Luego se aplica una capa de normalización por lotes que estabiliza la distribución de los datos antes de pasarlos al módulo de atención.
Y llegamos al bloque clave: TimeMoEAttention. Se trata de un módulo de atención multi-cabeza con múltiples expertos, cada uno centrado en un aspecto diferente de la información temporal. Como entrada, recibe tokens compactos del estado actual y del contexto de todo la historia; por lo tanto, los tokens literalmente extraen el significado de los datos. Las dimensionalidades de entrada y el número de expertos (NExperts) se especifican mediante parámetros, y cada nodo selecciona los K mejores, formando una representación latente comprimida pero expresiva. Esta capa es la última del codificador y define el espacio de salida al que se referirán posteriormente todos los modelos predictivos y de control.
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTimeMoEAttention; descr.window_out = EmbeddingSize / 4; { uint temp[] = {prev_out, prev_out, 8, TopK}; //Window Main, Window Cross, Experts dimension, TopK if(ArrayCopy(descr.windows, temp) < ArraySize(temp)) return false; } { uint temp[] = {prev_var, prev_var * prev_count, NExperts}; //Units Main, Units Cross, Experts if(ArrayCopy(descr.units, temp) < ArraySize(temp)) return false; } descr.layers = 6; descr.step = 4; // Attention heads descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
A la salida del codificador, se forma un array latente común, accesible a todos los modelos. El primer modelo de pronóstico, forecast1, lo toma tal cual. La capa de entrada básica recibe un tensor, y después se aplica un procesamiento convolucional sencillo que genera una previsión para un horizonte de planificación determinado para cada canal analizado. En este caso, solo se predice un valor subsiguiente.
//--- CLayerDescription *latent = descr; //--- Forecast 1 forecast1.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = int(latent.windows[0] * latent.units[0]); descr.activation = None; descr.optimization = ADAM; if(!forecast1.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = latent.units[0]; descr.window = latent.windows[0]; descr.step = descr.window; descr.layers = 1; descr.window_out = 1; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = TANH; if(!forecast1.Add(descr)) { delete descr; return false; }
Aquí debemos prestar especial atención a un matiz técnico importante. Durante el preprocesamiento de datos realizado por el codificador de estado del entorno, el espacio de características original se ha ampliado intencionalmente. Esta extensión resulta necesaria para extraer características de alto nivel y formar una representación latente más expresiva. Sin embargo, dicha transformación conlleva una discrepancia de dimensionalidad entre los valores predichos y los datos reales con los que trabajamos durante la fase de entrenamiento.
Para garantizar que los resultados previstos se comparen correctamente con los valores reales, deberemos ajustar las previsiones a la escala y estructura del espacio original. En este caso, lograremos esto mediante el uso de una capa totalmente conectada simple que reduce la dimensionalidad de los datos a un valor BarDescr determinado.
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = BarDescr; descr.activation = TANH; descr.optimization = ADAM; if(!forecast1.Add(descr)) { delete descr; return false; }
El segundo punto que merece ser analizado aparte es la restauración de la distribución original de los datos perdida durante la normalización. Anteriormente, usábamos la capa de normalización inversa RevIN para este propósito, pues extrae automáticamente los parámetros de normalización de la capa correspondiente en el Codificador. Sin embargo, la implementación actual ha introducido una limitación arquitectónica: la capa de normalización se ha quedado dentro del Codificador del entorno y no es directamente accesible desde los modelos predictivos. En otras palabras, RevIN simplemente no funcionará aquí; no hay acceso a las estadísticas guardadas del paquete.
La solución puede parecer paradójica a primera vista: volveremos a usar la capa de normalización por lotes, pero no para normalizar, sino para restaurar la escala original. Sí, sí, suena extraño, pero en la práctica es una técnica totalmente correcta.
La cuestión es que la arquitectura BatchNorm incluye dos parámetros en la salida: el escalado (scale) y el desplazamiento (bias), que pueden ser entrenados. Si bien tradicionalmente se usan para estabilizar y acelerar el aprendizaje, en nuestro caso desempeñan un papel diferente: el aprendizaje de la transformación inversa que acerca los valores predichos a la distribución original. Así pues, en lugar de almacenar estadísticas de normalización, dejaremos que el modelo aprenda a desnormalizar los datos por sí mismo, adaptándose a las condiciones del mundo real.
Este enfoque no carece de elegancia: mantiene la estructura del modelo compacta, evita la complejidad adicional del búfer de estado y, lo que es importante, no rompe el grafo computacional, lo cual garantiza una compatibilidad total con la lógica de propagación inversa actual.
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNorm; descr.count = BarDescr; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!forecast1.Add(descr)) { delete descr; return false; }
Todo está construido de la forma más eficiente posible: una ventana de constante, una sola capa y una longitud fija.
El segundo y tercer modelo de pronóstico (forecast2, forecast3) utilizan los mismos datos de entrada que el primero. Y prácticamente reproducen su arquitectura, pero se diferencian por su mayor ventana de previsión. Los parámetros de la capa convolucional predictiva se copian de la primera capa, pero el número de filtros se amplía hasta el horizonte de planificación requerido.
//--- Forecast 2 forecast2.Clear(); //--- Input layer if(!forecast2.Add(forecast1.At(0))) return false; //--- layer 1 if(!(descr = new CLayerDescription())) return false; if(!descr.Copy(forecast1.At(1))) { delete descr; return false; } prev_out = descr.window_out = NForecast / 2; if(!forecast2.Add(descr)) { delete descr; return false; } prev_count = descr.count;
Otro punto técnicamente esencial se refiere a la arquitectura del bloque de pronóstico. A la salida de la capa convolucional, el modelo genera valores predictivos para secuencias temporales unitarias individuales, cada una de las cuales refleja la dinámica local de un indicador o característica específica. Esto hace que la estructura de pronóstico resulte espacialmente dispersa pero rica en información.
Y nos surge entonces la pregunta: ¿cómo se pueden generalizar estas predicciones y transformarlas en un formato comparable con los datos originales? Formalmente, sería posible usar una capa clásica totalmente conectada, ya que es perfectamente capaz de agregar información. Sin embargo, a medida que aumenta el horizonte de planificación (y, en consecuencia, la longitud de la secuencia de resultados), dicha implementación pierde su eficiencia. El número de parámetros crece exponencialmente y, con el tiempo, las dependencias locales comienzan a perderse.
Para evitar esto, primero transpondremos el tensor, reorganizando los ejes de manera que los pasos de tiempo se conviertan en canales independientes. Esto permite aplicar la siguiente capa convolucional a cada segmento de tiempo por separado. En otras palabras, en lugar de procesar toda la secuencia como un todo, le daremos al modelo la capacidad de analizar cada momento en el tiempo a través del prisma de sus propias características. Esta operación ofrece un doble beneficio: reduce la dimensionalidad de la representación de salida (actuando así como un cuello de botella) y, al mismo tiempo, preserva la coherencia temporal entre los pasos.
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.window = prev_out; descr.count = prev_count; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!forecast2.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.window = prev_count; descr.step = prev_count; prev_count = descr.count = prev_out; descr.layers = 1; prev_out = descr.window_out = BarDescr; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = TANH; if(!forecast2.Add(descr)) { delete descr; return false; }
De esta manera, implementamos un mecanismo de agregación eficaz sin perder la estructura detallada del pronóstico. Esto es especialmente valioso al entrenar modelos con datos de alta multiplicidad y al utilizar características de multigrafo, donde cada segmento temporal aporta información específica que no puede reducirse a un patrón común.
Todo finaliza con una normalización por lotes que restaura la distribución de datos original.
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count * prev_out; descr.activation = None; if(!forecast2.Add(descr)) { delete descr; return false; }
El modelo funciona como una previsión a largo plazo, usando las mismas características pero interpretándolas a través de una lente amplificada.
En cuanto al tercer modelo de pronóstico, su arquitectura es completamente idéntica a la del segundo. La única diferencia reside en el horizonte de planificación ampliado, que nos permite mirar más allá en el futuro. Por lo demás, son las mismas capas convolucionales y de transposición, la misma lógica de concordancia de dimensionalidades y de normalización. Por ello, para no sobrecargar la presentación con repeticiones, omitiremos su descripción detallada y nos centraremos en los modelos de toma de decisiones.
A continuación, viene el modelo del Actor, es decir, el ejecutor de la estrategia. Como entrada, recibe los descriptores del estado actual de la cuenta. Los datos obtenidos se someten a una normalización estándar.
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = AccountDescr; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Posteriormente, se introducen en el mecanismo CrossDMHAttention. Se trata de la atención cruzada, donde el flujo principal es la información actual de la cuenta y el contexto son las características latentes del Codificador. Este enfoque permite al mecanismo de atención extraer las características más relevantes del estado del mercado, considerando la situación actual del usuario. Dentro del bloque, se utiliza una pila de 3 capas de atención, cada una de las cuales utiliza múltiples cabezas.
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossDMHAttention; { uint temp[] = {AccountDescr, // Inputs window latent.windows[0] // Cross window }; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } { uint temp[] = {1, // Inputs units latent.units[0] // Cross units }; if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.step = 4; // Heads descr.window_out = 32; descr.batch = 1e4; descr.layers = 3; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Después de la atención viene una cadena de tres capas totalmente conectadas que transforman los datos recibidos en probabilidades de acciones (NActions). Este resultado es el que se usa para generar decisiones de inversión.
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.batch = BatchSize; descr.activation = TANH; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SoftPlus; descr.batch = BatchSize; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = SIGMOID; descr.batch = BatchSize; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
De este manera, toda la arquitectura está estructurada de forma secuencial, lógica y modular: desde el procesamiento preliminar de datos históricos, pasando por mecanismos de transformación y atención multi-cabeza, hasta la previsión y la toma de decisiones. Este enfoque garantiza transparencia, fiabilidad y suficiente flexibilidad, algo especialmente importante para las aplicaciones comerciales del mundo real, donde la arquitectura no tolera la falta de unidad. Todo está vinculado a un espacio latente común, y cada modelo funciona en su propio contexto sin perder la coherencia global.
Las arquitecturas de los modelos del Crítico y el Director se construyen por analogía con el Actor. La única diferencia es que la entrada no supone el estado de la cuenta, sino un vector de acción generado por el propio Actor. Y en la salida, se forma una evaluación numérica de estas acciones: en el caso del Crítico, este es el valor de la función value, y para el Director, es el gradiente de la señal de la clasificación binaria (acción buena/mala). La estructura interna es la misma: bloques de normalización, mecanismo de atención cruzada y cascada de capas de salida. Para no sobrecargar el texto con la repetición de detalles ya descritos, no nos detendremos en estos elementos con detalle. La arquitectura completa de todos los componentes se presenta en el archivo adjunto.
Entrenamiento de modelos
La siguiente etapa de nuestro trabajo consiste en entrenar los modelos. Y aquí nos espera uno de los desafíos clave. Lo cierto es que los autores del framework original Time-MoE entrenaron sus redes neuronales con una muestra masiva, si no titánica, Time-300B. Esta muestra abarca más de 300 mil millones de puntos temporales de nueve áreas temáticas distintas, entre las que se incluyen la economía, la energía, el transporte, la sanidad y otros ámbitos. Este volumen de datos ofrece al modelo una impresionante capacidad de generalización, pero también requiere recursos que no están disponibles mediante el entrenamiento local o semiautomatizado.
Reproducir una muestra de este tipo en condiciones domésticas es una tarea poco realista. Sin embargo, limitarnos al entrenamiento online basado en los datos entrantes implica crear un sistema inherentemente incapaz de aprender patrones estables. Especialmente en condiciones de alta volatilidad del mercado y un periodo de observación limitado.
Por consiguiente, adoptaremos una solución de compromiso: abandonar la etapa de recopilación preliminar de una muestra de entrenamiento estática, pero mantener la controlabilidad del proceso de entrenamiento mediante la acumulación paso a paso y el uso de búferes de estado internos. En este caso, un mecanismo previamente probado en el modelo TimeFound nos ha resultado de gran ayuda, pues ha demostrado ser lo suficientemente eficaz como para volver a utilizarse, pero esta vez para entrenar no solo el modelo predictivo, sino también los modelos de toma de decisiones.
La esencia del mecanismo reside en entrenar a un agente con datos pseudoreales, donde la historia real de cotizaciones se obtiene del terminal, mientras que la evaluación de las acciones se lleva a cabo mediante un entorno simulado. Todo el proceso se construye dentro del marco del método Train del asesor "…\Experts\TimeMoE\Study.mq5". Aquí es donde comienza el procesamiento paso a paso de los datos históricos, la formación de paquetes de entrenamiento, la recopilación del contexto de las cuentas comerciales y el entrenamiento secuencial del modelo basado en la propagación inversa de errores.
En la primera etapa, el método determina los límites temporales del entrenamiento y se calculan los índices de inicio y fin del periodo histórico en el que se llevará a cabo el entrenamiento.
void Train(void) { int start = iBarShift(Symb.Name(), TimeFrame, Start); int end = iBarShift(Symb.Name(), TimeFrame, End); int bars = CopyRates(Symb.Name(), TimeFrame, 0, start, Rates);
A continuación, se cargan las cotizaciones y se inicializan los principales indicadores técnicos. Luego se comprueba si cada uno de ellos está listo; si los cálculos aún no han finalizado, el sistema espera con breves pausas. Esto es necesario para inicializar los búferes de forma estable; de lo contrario, el procesamiento posterior simplemente no tendrá sentido.
if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } //--- int count = -1; bool calculated = false; do { count++; calculated = (RSI.BarsCalculated() >= bars && CCI.BarsCalculated() >= bars && ATR.BarsCalculated() >= bars && MACD.BarsCalculated() >= bars ); Sleep(100); count++; } while(!calculated && count < 100); if(!calculated) { PrintFormat("%s -> %d The training data has not been loaded", __FUNCTION__, __LINE__); ExpertRemove(); return; } RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); //--- if(!ArraySetAsSeries(Rates, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } bars -= end + HistoryBars + NForecast; if(bars < 0) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; }
Una vez que los indicadores están listos, comenzará la recopilación de patrones para el entrenamiento. Así, organizamos un ciclo de entrenamiento, en cada iteración del cual se selecciona una posición aleatoria dentro del rango aceptable de barras. Este es el punto de entrada a la siguiente escena de entrenamiento. Partiendo de esta posición, se forman tres vectores clave:
- bState: describe el estado del mercado en forma de un tensor de indicadores y parámetros de precios;
- bTime: refleja el contexto temporal;
- Result: contiene una previsión de referencia basada en la evolución futura de los precios.
vector<float> result, target, neg_target; bool Stop = false; //--- uint ticks = GetTickCount(); //--- for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int posit = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * bars); if(!CreateBuffers(posit + end, GetPointer(bState), GetPointer(bTime), Result)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; }
Además del estado del mercado, se forma un pseudoestado de la cuenta: una simulación del contexto comercial del tráder en esta etapa. Esto ocurre en la función SampleAccount, que crea un vector de características que incluye saldo, equidad, tamaño de la posición, beneficio acumulado, riesgo para obtener beneficios y ratio de stop loss, así como ondas sinusoidales que modelan patrones estacionales ocultos.
const vector<float> account = SampleAccount(GetPointer(bState), datetime(bTime[0])); if(!bAccount.AssignArray(account)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; }
Este vector se introduce en el actor como dato de entrada, ampliando su comprensión de la situación actual: el agente toma una decisión no solo según el mercado, sino también en el contexto de su posición en la transacción.
Una vez generados los datos iniciales, comienza la pasada directa del modelo: El Codificador codifica el mercado, después de lo cual tres bloques de pronóstico [i] construyen pronósticos en diferentes horizontes temporales.
//--- Feed Forward if(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bTime))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } for(uint f = 0; f < caForecast.Size(); f++) if(!caForecast[f].feedForward(GetPointer(cEncoder), -1, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d - Forecast %d", __FUNCTION__, __LINE__, f); Stop = true; break; }
Al mismo tiempo, el Actor genera una decisión comercial, que es evaluada inmediatamente por dos modelos independientes: el Crítico y el Director. Uno analiza la decisión desde el punto de vista del enfoque clásico value, el otro, como un clasificador binario que produce una fuerte retroalimentación que separa las buenas acciones de las obviamente incorrectas.
if(!cActor.feedForward(GetPointer(bAccount), 1, false, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cCritic.feedForward(GetPointer(cActor), -1, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cDirector.feedForward(GetPointer(cActor), -1, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
A continuación, optimizamos los parámetros del modelo mediante el método de pasada inversa. Para entrenar modelos predictivos, se usa un tensor preelaborado de estados sucesivos del entorno.
//--- Study for(uint f = 0; f < caForecast.Size(); f++) if(!caForecast[f].backProp(Result, (CBufferFloat*)NULL) || !cEncoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d - Forecast %d", __FUNCTION__, __LINE__, f); Stop = true; break; }
Para obtener una evaluación objetiva de una acción, se usa la función CheckAction. Esta simula la apertura de una posición virtual y calcula el beneficio esperado considerando el factor de descuento basado en datos históricos reales. En función de estos datos, se genera una recompensa que se devuelve al modelo y se convierte en la base para recalcular los parámetros de todos los componentes: el Actor, el Crítico y el Director.
cActor.getResults(Action); double equity = bAccount[2] * bAccount[0] * EtalonBalance / (1 + bAccount[1]); double reward = CheckAction(Action, Result, equity); Result.Clear(); if(!Result.Add(float(reward))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cCritic.backProp(Result, GetPointer(cEncoder), LatentLayer) || !cActor.backPropGradient(GetPointer(cEncoder), LatentLayer, -1, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!Result.Update(0, float(reward > 0))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cDirector.backProp(Result, GetPointer(cEncoder), LatentLayer) || !cActor.backPropGradient(GetPointer(cEncoder), LatentLayer, -1, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
La corrección del gradiente se realiza rápidamente, en una sola actualización. Los errores resultantes se retroalimentan a la red y los pesos se actualizan de acuerdo con la señal de entrenamiento. Esto permite que el modelo acumule gradualmente patrones útiles y ajuste su comportamiento según los nuevos ejemplos, aprendiendo a evitar errores y a reforzar las acciones útiles.
Para supervisar visualmente el proceso en tiempo real, el método muestra periódicamente parámetros clave de entrenamiento en los comentarios del gráfico: errores de pronóstico y precisión del crítico.
if(GetTickCount() - ticks > 500) { double percent = double(iter) * 100.0 / (Iterations); string str = ""; for(uint f = 0; f < caForecast.Size(); f++) str += StringFormat("%-12s%d %6.2f%% -> Error %15.8f\n", "Forecast", f, percent, caForecast[f].getRecentAverageError()); str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Critic", percent, cCritic.getRecentAverageError()); str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Director", percent, cDirector.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
Tras completar el número de iteraciones especificado, el método muestra los resultados del entrenamiento en el registro de la terminal y finaliza correctamente la ejecución del programa a través de ExpertRemove.
Comment(""); //--- for(uint f = 0; f < caForecast.Size(); f++) PrintFormat("%s -> %d -> %-15s%d %10.7f", __FUNCTION__, __LINE__, "Forecast", f, caForecast[f].getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", cCritic.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Director", cDirector.getRecentAverageError()); ExpertRemove(); //--- }
De esta manera, cada entrenamiento consiste en una serie de pequeñas simulaciones, cada una de las cuales reproduce un fragmento de la historia: desde el estado del mercado hasta el resultado de las operaciones. El agente aprende sobre la marcha, mediante errores y éxitos, adaptándose gradualmente a las diversas condiciones del mercado y mejorando su propia estrategia usando un sistema de evaluación equilibrado y ajustes específicos.
Tras la fase de entrenamiento principal, el modelo pasa a la fase de ajuste fino, que se realiza en modo de entrenamiento en línea. Para ello, utilizamos el simulador de estrategias integrado de MetaTrader 5, que nos permite ejecutar el algoritmo en condiciones lo más parecidas posible al mercado real, pero con control total sobre la simulación. Este enfoque nos permite no solo probar la calidad del modelo, sino también adaptar su comportamiento al entorno actual del mercado, ajustando gradualmente los parámetros a la volatilidad y al movimiento de los precios.
El mecanismo de aprendizaje online se implementa a partir del programa transferido de trabajos anteriores, sin modificaciones. El modelo continúa aprendiendo de nuevos datos y ajustando sus acciones sobre la marcha, lo que resulta especialmente importante en condiciones de mercado que cambian con frecuencia.
Simulación
El proceso de entrenamiento del modelo se ha dividido en dos etapas. Este enfoque nos ha permitido construir el sistema de forma coherente, fiable y sin prisas.
Primero, el entrenamiento offline. Utilizamos quince años de historia para el par EURUSD, marco temporal M1. Esto da al modelo una enorme variedad de situaciones de mercado diferentes. El Codificador ha aprendido a reconocer patrones, identificar los más significativos y codificar las condiciones del mercado en un vector de características compacto y completo. Este vector se convierte en la base de todas las decisiones que toma el agente. Durante el proceso de entrenamiento, el Actor domina la estrategia de comportamiento, recibiendo señales del Crítico y del Director.
Luego, la configuración online. Esta se realiza en el simulador de estrategias de MetaTrader 5. Aquí, el modelo interactúa con la historia de forma realista: vela por vela, con ruido de mercado, fluctuaciones aleatorias e inestabilidad. Esto ayuda a adaptar el comportamiento del agente a la dinámica del entorno real y a ajustar la estrategia en condiciones lo más parecidas posible a las reales.
Tras el entrenamiento, el modelo se ha probado con nuevos datos: cotizaciones correspondientes a enero de 2025. Todos los ajustes se ha fijado de antemano y no se han modificado. Esto garantiza la objetividad y la transparencia de la evaluación. Los resultados de la prueba se muestran a continuación.

Los resultados de las pruebas parecen contradictorios y deben interpretarse con precaución. Por un lado, la rentabilidad total es tradicionalmente importante: con un depósito inicial de 100 dólares, el sistema ha finalizado el periodo con una ganancia neta de 1 209 dólares. Esto equivale a 12 veces el capital inicial. El gráfico de saldos muestra un crecimiento constante hasta mediados de mes, y luego una estabilización relativa en torno a los 1 350-1 400 dólares.
Sin embargo, la magnitud extrema de la caída resulta evidente de inmediato. La caída máxima del balance superó el 72%, y la de equidad, el 87%. Esto significa que, en los momentos de mayor volatilidad, el sistema ha perdido la mayor parte de su capital. Esto caracteriza la política de conducta como de alto riesgo, incluso a pesar de la recuperación posterior.
El factor de beneficio ha sido de 1,49, lo que muchos consideran un nivel aceptable, pero con tales reducciones de capital difícilmente compensa los posibles periodos de pérdida. El factor de recuperación (la relación entre el beneficio neto y la pérdida máxima) es de casi 1, lo que indica una recuperación muy lenta de las pérdidas.
Las estadísticas comerciales muestran que durante el mes se han abierto cerca de 2 500 operaciones, de las cuales el 53,98% han sido rentables. La ganancia promedio es solo ligeramente superior a la pérdida promedio.
En general, estos resultados demuestran que el modelo es capaz de encontrar señales rentables y favorecer el crecimiento del depósito, pero lo hace a costa de grandes pérdidas y largas rachas de operaciones perdedoras. Para negociar en la práctica, dicha estrategia requiere protección adicional para reducir el riesgo de una pérdida importante de capital.
Conclusión
En este artículo, hemos transferido paso a paso las ideas clave del framework Time-MoE del artículo académico a código real en MQL5 y OpenCL. Asimismo, hemos implementado el método de incorporación SwiGLU, hemos construido una mezcla de expertos dispersa y la hemos integrado en un mecanismo de atención cruzada. Además, hemos organizado un programa de entrenamiento completo dentro de la estructura Actor-Director-Crítico. Gracias a una estructura modular clara, todos los componentes están interconectados, pero a la vez son fácilmente personalizables y ampliables.
La primera fase de entrenamiento offline con quince años de datos ha permitido al Codificador formar una rica representación latente del mercado, y al Actor dominar la estrategia básica bajo la supervisión del Crítico y el Director. La segunda fase de ajuste online en el simulador de MetaTrader 5 ha permitido que el modelo esté listo para operar, reflejando la dinámica y el ruido del mercado moderno en sus parámetros.
Las pruebas realizadas con cotizaciones de enero de 2025 han demostrado que el Agente es capaz de generar beneficios estables, pero esto va acompañado de importantes pérdidas. Esto indica la necesidad de optimizar aún más la gestión de riesgos y ajustar con precisión los criterios de negociación.
Enlaces
- Time-MoE: Billion-Scale Time Series Foundation Models with Mixture of Experts
- Otros artículos de la serie
Programas usados en el artículo
| # | Nombre | Tipo | Descripción |
|---|---|---|---|
| 1 | Study.mq5 | Asesor | Asesor de entrenamiento de modelos offline |
| 2 | StudyOnline.mq5 | Asesor | Asesor de entrenamiento de modelos online |
| 3 | Test.mq5 | Asesor | Asesor para la prueba de modelos |
| 4 | Trajectory.mqh | Biblioteca de clases | Estructura de descripción del estado del sistema y la arquitectura del modelo |
| 5 | NeuroNet.mqh | Biblioteca de clases | Biblioteca de clases para crear una red neuronal |
| 6 | NeuroNet.cl | Biblioteca | Biblioteca de código del programa OpenCL |
Traducción del ruso hecha por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/ru/articles/18548
Advertencia: todos los derechos de estos materiales pertenecen a MetaQuotes Ltd. Queda totalmente prohibido el copiado total o parcial.
Este artículo ha sido escrito por un usuario del sitio web y refleja su punto de vista personal. MetaQuotes Ltd. no se responsabiliza de la exactitud de la información ofrecida, ni de las posibles consecuencias del uso de las soluciones, estrategias o recomendaciones descritas.
Utilizando redes neuronales en MetaTrader
Características del Wizard MQL5 que debe conocer (Parte 66): Uso de patrones FrAMA y Force Index con el núcleo de producto escalar
Particularidades del trabajo con números del tipo double en MQL4
Redes neuronales en el trading: Pipeline de pronóstico inteligente (Time-MoE)
- 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