Русский Português
preview
Redes neuronales en el trading: descomposición en lugar de escalado (Final)

Redes neuronales en el trading: descomposición en lugar de escalado (Final)

MetaTrader 5Sistemas comerciales |
74 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

Los mercados financieros actuales no son solo un lugar de encuentro entre vendedor y comprador, sino un ecosistema dinámico complejo en el que cada tick del precio es el resultado de la interacción instantánea de cientos de factores. Aquí no hay lugar para la casualidad en el sentido habitual del término: tras el aparente caos se esconden regularidades, aunque revestidas de formas complejas y multicapa. Es precisamente la capacidad de reconocer estas estructuras ocultas y anticipar su evolución lo que determina el éxito de un tráder o de un sistema algorítmico. En los artículos anteriores nos familiarizamos con el framework SSCNN (Spatial-Sequential Convolutional Neural Network). Detrás de este nombre, a primera vista algo engorroso, se esconde un concepto muy coherente y elegante que aúna las dependencias espaciales y temporales en un único algoritmo computacional.

SSCNN se basa en la idea de que una serie temporal es una huella multidimensional desplegada a lo largo del tiempo. Cada instante está vinculado no solo con el pasado y el futuro, sino también con estructuras paralelas: espacios internos de características que cambian de forma sincronizada o con un desfase.

Para captar estas relaciones, el modelo utiliza una cascada de bloques capaces de extraer patrones locales y módulos de procesamiento secuencial que conservan y acumulan el contexto. El elemento clave aquí es el módulo Attention-based Normalization, una combinación de atención y normalización que permite al sistema centrarse en las partes de los datos que son realmente significativas, al tiempo que estabiliza el proceso de entrenamiento. Gracias a esta arquitectura, SSCNN es capaz no solo de analizar señales con distintos niveles de detalle, sino también de adaptarse a una estructura de datos cambiante, lo cual resulta especialmente importante en el caso de los instrumentos financieros, con su volatilidad variable y los cambios en los modos de mercado.

Otra ventaja importante es la modularidad. La arquitectura SSCNN es escalable y se puede configurar para una tarea concreta, ya sea la previsión a corto plazo del impulso del precio o la búsqueda de ciclos a largo plazo. El concepto de procesamiento jerárquico por capas, en el que cada nivel del modelo extrae su propia parte de características y la transmite al siguiente, aporta flexibilidad y robustez.

En publicaciones anteriores analizamos en detalle cómo estas ideas se plasmaron en código y cómo las integramos en el entorno MQL5, sentando así las bases para un sistema de trading completo.

Ahora llegamos a la etapa final, en la que la teoría debe ponerse a prueba en la práctica, en su forma más exigente. Tenemos que integrar los distintos bloques en una arquitectura integral del framework y evaluar cómo SSCNN procesa e interpreta los datos de mercado. Asimismo, comprobaremos la eficacia de las soluciones implementadas en condiciones lo más cercanas posible al trading real. Este paso es importante no solo para medir la precisión de las previsiones o la estabilidad del modelo, sino también para comprender hasta qué punto sus algoritmos perciben el mercado en profundidad, distinguen estructuras ocultas y son capaces de reconocer el cambio de contexto. Al fin y al cabo, el éxito de cualquier sistema de trading no viene determinado por el brillo de su arquitectura, sino por cómo gestiona el ruido, los picos inesperados y los giros traicioneros de los gráficos.


El codificador

En artículos anteriores construimos bloques independientes de la arquitectura SSCNN y hoy empezamos a conectarlos en un único codificador: ese mismo nodo en el que múltiples flujos de información (tendencias a largo plazo, patrones estacionales, fluctuaciones a corto plazo y relaciones espaciales) se descomponen cuidadosamente, se ordenan, generan sus proyecciones para un horizonte de planificación determinado y, a continuación, se fusionan en una representación predictiva unificada.

La clase CNeuronSSCNNEncoder plasma esta idea en código. Su función no consiste simplemente en llamar a una serie de módulos de forma secuencial, sino en sincronizar su funcionamiento: aislar cada componente según su propia lógica, extrapolarla al horizonte requerido, predecir las estadísticas correspondientes y, solo entonces, reunir todo ello en una representación rica y estructurada para la regresión polinómica final. El codificador gestiona los flujos de datos, organiza las transposiciones cuando son necesarias para el correcto funcionamiento por fases y garantiza que cada resultado producido por un subsistema ocupe su lugar en la concatenación con el formato adecuado. Veamos la estructura de la clase:

class CNeuronSSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronConvOCL          cLongExtrapolate;
   CNeuronTransposeOCL     cLongMeanSTDevTransp;
   CNeuronBaseOCL          cLongMeanExtrapolate;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronAttentNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronConvOCL          cSeasonExtrapolate;
   CNeuronConvOCL          cSeasonMeanExtrapolate;
   CNeuronAttentNorm       cShortNorm;
   CNeuronConvOCL          cShortExtrapolate;
   CNeuronConvOCL          cShortMeanExtrapolate;
   CNeuronSAttentNorm      cSpatialNorm;
   CNeuronConvOCL          cSpatialExtrapolate;
   CNeuronConvOCL          cSpatialMeanExtrapolate;
   CNeuronBaseOCL          cConcatenated;
   CNeuronTransposeOCL     cTranspose;
   CNeuronPolynomialRegression   cFusion;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSSCNNEncoder(void) {};
                    ~CNeuronSSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSSCNNEncoder; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  }; 

El flujo de trabajo dentro del codificador está bien organizado. Todos los objetos internos están definidos de forma fija dentro de la clase, por lo que el constructor y el destructor de la clase permanecen vacíos, y la construcción efectiva del grafo de cálculo se lleva a cabo de forma centralizada en el método Init.

bool CNeuronSSCNNEncoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                               uint units_count, uint variables, uint forecast,
                               uint season_period, uint short_period,
                               ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, units_count + forecast,
                                variables, optimization_type, batch))
      return false;
   activation = None;

El método comienza con la inicialización de la clase padre CNeuronTransposeOCL, a la que se pasan los parámetros básicos de la arquitectura que se va a crear. Esta llamada prepara la infraestructura general: la reserva de búferes globales, la configuración del tamaño de los tensores y la vinculación del contexto OpenCL.

Cabe señalar que la dimensión del tensor de resultados se indica como la suma de la longitud de la secuencia analizada y el horizonte de planificación. Aquí queda claro que, a la salida del módulo, pretendemos obtener los datos originales depurados y la previsión necesaria en una representación única y comparable.

Inmediatamente después de la inicialización básica, desactivamos explícitamente la función de activación de la capa actual (activation = None), ya que el codificador trabaja con representaciones numéricas y estadísticas.

A continuación, procedemos a la inicialización de los objetos internos. La primera es la rama a largo plazo: cLongNorm. Aquí se crea un módulo de normalización por periodos, que prepara la componente estable de la serie (tendencia).

   int index = 0;
   if(!cLongNorm.Init(0, index, OpenCL, 1, units_count, iWindow, optimization, iBatch))
      return false;

Luego inicializamos cLongExtrapolate, un bloque de extrapolación de la tendencia que ampliará la componente a largo plazo hasta el horizonte de pronóstico.

   index++;
   if(!cLongExtrapolate.Init(0, index, OpenCL, units_count, units_count, iCount, iWindow, 1, optimization, iBatch))
      return false;
   cLongExtrapolate.SetActivationFunction(None);

Aquí cabe señalar que, en la implementación del autor, para la extrapolación de datos se utiliza la matriz de parámetros entrenables E.

En nuestra implementación, decidimos utilizar una capa convolucional sin función de activación para realizar dicha función. Una capa convolucional sin activación también es un operador lineal, pero con ventajas adicionales de ingeniería. Las convoluciones aportan esparsidad paramétrica (pesos compartidos), están optimizadas a nivel de hardware y, además, describen de forma natural las transformaciones locales y cuasilocales que suelen aparecer en las series temporales.

Una vez que hemos extrapolado los residuos de la componente a largo plazo, toca extrapolar los propios valores medios. Un matiz práctico es que CNeuronPeriodNorm devuelve un tensor en el que los valores se registran por pares: la media y la varianza. En lugar de realizar una deconcatenación explícita y copiar los datos, recurrimos a un recurso elegante: una simple transposición del tensor.

La transposición reorganiza los datos de tal forma que el pipeline construye secuencialmente las secuencias separadas de todas las medias y todas las varianzas, de modo que la secuencia de las medias queda en primer lugar. Esto permite pasar el tensor transpuesto directamente al módulo de extrapolación de medias. En esencia, aprovechamos la propiedad del orden de los elementos en la memoria para obtener flujos de datos separados lógicamente con un esfuerzo mínimo.

   index++;
   if(!cLongMeanSTDevTransp.Init(0, index, OpenCL, iWindow, 2, optimization, iBatch))
      return false;
   if(!cLongMeanSTDevTransp.getGradient().Fill(0))
      return false;
   index++;
   if(!cLongMeanExtrapolate.Init(0, index, OpenCL, Neurons(), optimization, iBatch))
      return false;
   cLongExtrapolate.SetActivationFunction(None);
   if(!cLongMeanExtrapolate.getPrevOutput().Fill(1))
      return false

Pero hay otros dos aspectos bastante concretos, aunque igualmente importantes, de los que conviene ocuparse con antelación. El primero es la protección contra la contaminación de los gradientes de las varianzas. Dado que nuestro módulo de extrapolación solo trabaja con valores medios, debemos asegurarnos de que no se introduzcan en la memoria de gradientes de las varianzas valores aleatorios que puedan distorsionar la siguiente pasada inversa. En la implementación, esto se resuelve de forma sencilla y fiable en la fase de inicialización: el búfer de gradientes del objeto de transposición se pone a cero. De este modo, eliminamos explícitamente cualquier carga residual de la memoria y descartamos la influencia de los valores basura en σ².

El segundo aspecto es la propia extrapolación de los valores medios. En la formulación original, los autores simplemente rellenan el tensor con los valores obtenidos. Nosotros, por nuestra parte, optamos por un enfoque eficiente desde el punto de vista de ingeniería: para obtener el tensor relleno que necesitamos, utilizamos el producto exterior (outer product) en su forma más sencilla. Así, tomamos un vector columna de medias µ de dimensión N•1 y lo multiplicamos por un vector fila de unos de dimensión 1•M. El resultado es una matriz N•M, en la que cada fila es una copia del µi correspondiente. Así se realiza el copiado de valores en un tensor del tamaño deseado sin bucles explícitos. Las ventajas prácticas de esta solución son evidentes: se trata de una operación lineal, fácilmente paralelizable en la GPU, eficiente en memoria y que se traduce con claridad a OpenCL.

En definitiva, con un código mínimo y una sobrecarga reducida, obtenemos un mecanismo de extrapolación de valores medios claro, comprensible e implementable de forma eficiente. Este conserva la semántica del planteamiento original de los autores (proyección lineal en el tiempo), pero la hace más eficiente y mejor adaptada a los cálculos paralelos en OpenCL.

Después viene la rama de la estacionalidad. En primer lugar, el objeto cSeasonTransp configura la transposición por ciclos, es decir, agrupamos los datos por fases de un mismo ciclo utilizando el parámetro season_period. Esto facilita la operación posterior de selección de fases idénticas y permite utilizar el módulo universal de normalización con coeficientes de atención CNeuronAttentNorm.

   index++;
   if(!cSeasonTransp.Init(0, index, OpenCL, iWindow, units_count / season_period, season_period,
                                                                          optimization, iBatch))
      return false;
   index++;
   if(!cSeasonNorm.Init(0, index, OpenCL, season_period, cSeasonTransp.GetCount(), iWindow,
                                                                          optimization, iBatch))
      return false;
   index++;
   if(!cUnSeasonTransp.Init(0, index, OpenCL, iWindow, season_period, cSeasonTransp.GetCount(),
                                                                          optimization, iBatch))
      return false;

El objeto cSeasonNorm extrae la componente estacional y normaliza los valores residuales, mientras que cUnSeasonTransp devuelve los datos a su orientación original antes de la extrapolación. La extrapolación propiamente dicha de las componentes estacionales se realiza mediante los objetos cSeasonExtrapolate y cSeasonMeanExtrapolate.

   index++;
   if(!cSeasonExtrapolate.Init(0, index, OpenCL, units_count, units_count, iCount, iWindow, 1,
                                                                                optimization, iBatch))
      return false;
   cSeasonExtrapolate.SetActivationFunction(None);
   index++;
   if(!cSeasonMeanExtrapolate.Init(0, index, OpenCL, season_period, season_period, iCount, iWindow, 1,
                                                                                optimization, iBatch))
      return false;
   cSeasonMeanExtrapolate.SetActivationFunction(None);

La rama de corto plazo se define mediante un conjunto similar de módulos, pero sin transponer los datos. El objeto cShortNorm extrae los efectos locales y rápidos en la ventana short_period; cShortExtrapolate construye su pronóstico, mientras que cShortMeanExtrapolate construye el pronóstico de las estadísticas de la capa de corto plazo. Tenga en cuenta que, en los parámetros de inicialización del objeto de extrapolación de valores medios, utilizamos cShortNorm.GetUnits() para alinear los formatos; esto garantiza que los bloques posteriores acepten correctamente las salidas del normalizador como entradas.

   index++;
   if(!cShortNorm.Init(0, index, OpenCL, units_count / short_period, short_period, iWindow, optimization, iBatch))
      return false;
   index++;
   if(!cShortExtrapolate.Init(0, index, OpenCL, units_count, units_count, iCount, iWindow, 1, optimization, iBatch))
      return false;
   cShortExtrapolate.SetActivationFunction(None);
   index++;
   if(!cShortMeanExtrapolate.Init(0, index, OpenCL, cShortNorm.GetUnits(), cShortNorm.GetUnits(), iCount, iWindow,
                                                                                          1, optimization, iBatch))
      return false;
   cShortMeanExtrapolate.SetActivationFunction(None);
   index++;

A continuación, se inicializa la rama espacial: cSpatialNorm es un módulo S-AttnNorm que identifica patrones espacialmente coherentes entre las secuencias unitarias de las características individuales.

   if(!cSpatialNorm.Init(0, index, OpenCL, units_count, variables, optimization, iBatch))
      return false;
   index++;
   if(!cSpatialExtrapolate.Init(0, index, OpenCL, units_count, units_count, iCount, iWindow, 1,
                                                                             optimization, iBatch))
      return false;
   cSpatialExtrapolate.SetActivationFunction(None);
   index++;
   if(!cSpatialMeanExtrapolate.Init(0, index, OpenCL, units_count, units_count, iCount, 1, iWindow,
                                                                             optimization, iBatch))
      return false;
   cSpatialMeanExtrapolate.SetActivationFunction(None);
   index++;

Le siguen cSpatialExtrapolate y cSpatialMeanExtrapolate, que predicen la componente espacial y sus estadísticas en un horizonte especificado.

A continuación, todos los componentes pronosticados y sus estadísticas se agrupan en un único tensor cConcatenated. Aquí multiplicamos explícitamente la dimensión del tensor por ocho, ya que en la salida de cada rama se forma un par residuo + media (Rlt, μlt, Rse, μse, Rst, μst, Rsi, μsi), es decir, ocho flujos de información por unidad.

   if(!cConcatenated.Init(0, index, OpenCL, 8 * Neurons(), optimization, iBatch))
      return false;
   index++;
   if(!cTranspose.Init(0, index, OpenCL, 8 * iWindow, iCount, optimization, iBatch))
      return false;

El tensor ancho resultante se transpone a continuación mediante el módulo cTranspose para adaptar los datos al formato que espera la capa final de fusión.

El último paso de la inicialización es cFusion: se trata de nuestro módulo de regresión polinómica (fusión), que toma como entrada un conjunto combinado de características de tamaño 8*iWindow y genera una representación compacta pero informativa de longitud iWindow para cada paso temporal. Precisamente aquí se implementa el esquema de combinación de efectos multiplicativos y aditivos entre los componentes.

   index++;
   if(!cFusion.Init(0, index, OpenCL, iCount, 8 * iWindow, iWindow, optimization, iBatch))
      return false;
//---
   return true;
  }

En cada etapa supervisamos el proceso de inicialización de los objetos y, en caso de que se produzca un error, el método devuelve false. Esta programación defensiva permite detener de inmediato la construcción del codificador ante cualquier problema. Si todos los módulos se han creado y configurado correctamente, el método finaliza con el resultado true, lo que indica que el codificador está listo para funcionar.

Tras inicializar la estructura del objeto, pasamos a organizar la pasada directa del codificador en el método feedForward, donde cada módulo aparece en el momento adecuado y pasa el relevo al siguiente. El método está estructurado por pasos y refleja la lógica de procesamiento de cuatro componentes: de largo plazo, estacional, de corto plazo y espacial; luego agrupa todo en un único vector de características y lo pasa a la fusión polinómica. En el código se ve claramente: en cada paso llamamos a FeedForward para el bloque correspondiente, comprobamos si se ha ejecutado correctamente y continuamos; si algo falla en algún punto, la función devuelve false y la cadena se detiene.

bool CNeuronSSCNNEncoder::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//--- Long
   if(!cLongNorm.FeedForward(NeuronOCL))
      return false;
   if(!cLongExtrapolate.FeedForward(cLongNorm.AsObject()))
      return false;
   if(!cLongMeanSTDevTransp.FeedForward(cLongNorm.GetMeanSTDevs()))
      return false;
   if(!MatMul(cLongMeanSTDevTransp.getOutput(), cLongMeanExtrapolate.getPrevOutput(),
              cLongMeanExtrapolate.getOutput(), 1, 1, iCount, iWindow, true))
      return false;

En primer lugar, se inicia la rama de largo plazo. La llamada a cLongNorm.FeedForward calcula la componente normalizada de largo plazo de los datos analizados; se trata de un paso preparatorio que separa la tendencia de los efectos locales. A continuación, cLongExtrapolate.FeedForward toma el resultado de la normalización y genera una previsión de los residuos de la componente de largo plazo para el horizonte deseado. Es importante señalar que este módulo recibe datos ya estructurados de cLongNorm.

Acto seguido, hay que extrapolar los estadísticos: las medias y las varianzas. Para ello, primero se transpone el tensor de estadísticos cLongMeanSTDevTransp, con el fin de agrupar las secuencias de medias por separado de las varianzas (ya hemos hablado anteriormente de esta técnica de transposición). Después se realiza la multiplicación de matrices. Aquí, en la práctica, proyectamos las estadísticas transpuestas al formato que necesitamos, utilizando un vector fila. Esta implementación ofrece el comportamiento deseado de replicación de las medias a lo largo del horizonte sin copias innecesarias.

A continuación comienza la rama estacional. En primer lugar, cSeasonTransp realiza la transposición de los datos analizados según las fases del ciclo; esto constituye la preparación para la normalización orientada a las fases.

//--- Season
   if(!cSeasonTransp.FeedForward(cLongNorm.AsObject()))
      return false;
   if(!cSeasonNorm.FeedForward(cSeasonTransp.AsObject()))
      return false;
   if(!cUnSeasonTransp.FeedForward(cSeasonNorm.AsObject()))
      return false;
   if(!cSeasonExtrapolate.FeedForward(cUnSeasonTransp.AsObject()))
      return false;
   if(!cSeasonMeanExtrapolate.FeedForward(cSeasonNorm.GetMeans()))
      return false;

Luego cSeasonNorm realiza una Attention-based Normalization por ciclos, seleccionando las posiciones de fase relevantes. La transposición inversa cUnSeasonTransp devuelve los datos a su forma original, listos para la extrapolación. A continuación, cSeasonExtrapolate recibe los residuos estacionales reconstruidos y los pronostica, mientras que cSeasonMeanExtrapolate extrapola las medias de las componentes estacionales obtenidas del normalizador.

La rama de corto plazo sigue un escenario similar:

  • cShortNorm toma los datos ya depurados de estacionalidad y extrae efectos locales y rápidos;
  • cShortExtrapolate pronostica estos residuos de corto plazo;
  • cShortMeanExtrapolate genera previsiones de sus estadísticas.

//--- Short
   if(!cShortNorm.FeedForward(cUnSeasonTransp.AsObject()))
      return false;
   if(!cShortExtrapolate.FeedForward(cShortNorm.AsObject()))
      return false;
   if(!cShortMeanExtrapolate.FeedForward(cShortNorm.GetMeans()))
      return false;

Es importante notar que la transferencia de datos de la rama estacional a la rama de corto plazo es una decisión deliberada: primero se eliminan los grandes ciclos y, a continuación, se captan los picos locales.

Luego viene la rama espacial. El módulo cSpatialNorm analiza conjuntos de series en un corte temporal e identifica estructuras espacialmente coherentes. Después de ello, cSpatialExtrapolate y cSpatialMeanExtrapolate realizan la predicción de los residuos espaciales y de sus estadísticas, respectivamente.

//--- Spatial
   if(!cSpatialNorm.FeedForward(cShortNorm.AsObject()))
      return false;
   if(!cSpatialExtrapolate.FeedForward(cSpatialNorm.AsObject()))
      return false;
   if(!cSpatialMeanExtrapolate.FeedForward(cSpatialNorm.GetMeans()))
      return false;

Esta secuencia garantiza que la normalización espacial vea los efectos locales, ya depurados de tendencia y estacionalidad.

Una vez que todas las ramas han generado sus pronósticos, se realiza un ensamblaje cuidadoso de las características. La primera llamada al método de concatenación Concat combina los residuos extrapolados y las medias de las ramas a largo plazo y estacional en un búfer amplio.

//--- Concat
   if(!Concat(cLongExtrapolate.getOutput(), cLongMeanExtrapolate.getOutput(),
              cSeasonExtrapolate.getOutput(), cSeasonMeanExtrapolate.getOutput(),
              cConcatenated.getOutput(), iCount, iCount, iCount, iCount, iWindow))
      return false;
   if(!Concat(cConcatenated.getOutput(), cShortExtrapolate.getOutput(),
              cShortMeanExtrapolate.getOutput(), cConcatenated.getPrevOutput(),
              4 * iCount, iCount, iCount, iWindow))
      return false;
   if(!Concat(cConcatenated.getPrevOutput(), cSpatialExtrapolate.getOutput(),
              cSpatialMeanExtrapolate.getOutput(), cConcatenated.getOutput(),
              6 * iCount, iCount, iCount, iWindow))
      return false;

Acto seguido, la segunda llamada a Concat añade los datos a corto plazo, ampliando el tensor acumulado. La tercera llamada al método de concatenación complementa el resultado con componentes espaciales.

Por último, llega la etapa de fusión. Previamente, cTranspose adapta el tensor ancho de los datos analizados a una forma adecuada para el regresor polinómico: se trata de una permutación de las dimensiones de las características y los pasos temporales, que hace que la estructura de datos sea adecuada para la agregación posterior. Y el módulo cFusion ejecuta la etapa de fusión polinómica: una combinación de vías aditivas y multiplicativas que ofrece una representación compacta, pero informativa, del pronóstico.

//--- Fusion
   if(!cTranspose.FeedForward(cConcatenated.AsObject()))
      return false;
   if(!cFusion.FeedForward(cTranspose.AsObject()))
      return false;
//---
   return CNeuronTransposeOCL::feedForward(cFusion.AsObject());
  }

El algoritmo de pasada directa finaliza con la llamada al método homónimo de la clase padre CNeuronTransposeOCL, que convierte el tensor de resultados en un conjunto de secuencias unitarias de características individuales, apto para su análisis por las capas neuronales posteriores del modelo.

En cada paso, el código comprueba rigurosamente el resultado de la ejecución de las operaciones. Y no se trata de formalismo, sino de una protección deliberada: cualquier excepción, error al asignar un búfer o formato incorrecto detiene inmediatamente la ejecución, lo que facilita la depuración y hace seguro el comportamiento del modelo en producción.

Desde el punto de vista arquitectónico, el orden de las llamadas refleja el principio de abajo hacia arriba: primero extraemos y extrapolamos los componentes gruesos y estables; a continuación, la secuencia de componentes de mayor resolución; después, analizamos las relaciones espaciales y, por último, combinamos cuidadosamente todo en la fusión. Este estilo facilita el rastreo de errores, aporta una semántica explícita a cada bloque y permite una validación por etapas de la calidad, desde la tendencia hasta los picos locales.

Si consideramos el método de pasada directa como un guion en el que vamos haciendo salir a escena a los actores uno tras otro. En este contexto, calcInputGradients es ese momento tras la representación en el que hay que desmontar con cuidado el atrezo, recopilar las observaciones del director y devolver la energía del error a cada módulo, permitiéndole corregir su comportamiento.

bool CNeuronSSCNNEncoder::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

En el cuerpo del método comprobamos inmediatamente la validez del puntero al objeto de datos de origen NeuronOCL. Una protección sencilla y necesaria: sin un puntero válido, nadie sabe dónde devolver los gradientes, por lo que salimos de forma controlada en caso de error.

A continuación comienza la fase de fusión. Dado que, en la pasada directa, el paso final fue la fusión polinómica y la transposición, en la pasada inversa primero obtenemos los gradientes de estos módulos.

//--- Fusion
   if(!CNeuronTransposeOCL::calcInputGradients(cFusion.AsObject()))
      return false;
   if(!cTranspose.CalcHiddenGradients(cFusion.AsObject()))
      return false;
   if(!cConcatenated.CalcHiddenGradients(cTranspose.AsObject()))
      return false;

Después llamamos al método homónimo de la clase padre CNeuronTransposeOCL, que transmite el error procedente del contexto externo al módulo de regresión polinómica, y propagamos los gradientes hasta el nivel del objeto de transposición cTranspose. En último lugar, pasamos los valores obtenidos al objeto de tensor ancho que contiene la concatenación de los resultados de todos los componentes, cConcatenated.

A continuación, debemos descomponer paso a paso la contribución de cada componente, realizando una deconcatenación secuencial (DeConcat) para desglosar el gradiente global en sus componentes.

//--- DeConcat
   if(!DeConcat(cConcatenated.getPrevOutput(), cSpatialExtrapolate.getGradient(),
                cSpatialMeanExtrapolate.getGradient(), cConcatenated.getGradient(),
                6 * iCount, iCount, iCount, iWindow))
      return false;
   if(!DeConcat(cConcatenated.getGradient(), cShortExtrapolate.getGradient(),
                cShortMeanExtrapolate.getGradient(), cConcatenated.getPrevOutput(),
                4 * iCount, iCount, iCount, iWindow))
      return false;
   if(!DeConcat(cLongExtrapolate.getGradient(), cLongMeanExtrapolate.getGradient(),
                cSeasonExtrapolate.getGradient(), cSeasonMeanExtrapolate.getGradient(),
                cConcatenated.getGradient(), iCount, iCount, iCount, iCount, iWindow))
      return false;

Primero extraemos los gradientes para la rama espacial (cSpatialExtrapolate y cSpatialMeanExtrapolate) de cConcatenated. Las dos siguientes llamadas al método DeConcat desglosan los gradientes para la componente de corto plazo, la estacional y la de largo plazo. En esencia, se distribuye un único búfer amplio entre los destinatarios: cada módulo recibe exactamente la parte del gradiente que ha generado en la pasada directa.

Ahora comienza la extracción secuencial de los gradientes por las ramas, en el orden lógico inverso al de su construcción. Para la rama espacial, primero transmitimos los gradientes de las estadísticas; se trata de la transferencia del error desde el bloque de predicción de las medias de la componente espacial al módulo de normalización, que se encargaba de dichas medias.

//--- Spatial
   if(!cSpatialNorm.GetMeans().CalcHiddenGradients(cSpatialMeanExtrapolate.AsObject()))
      return false;
   if(!cSpatialNorm.CalcHiddenGradients(cSpatialExtrapolate.AsObject()))
      return false;

A continuación, el método cSpatialNorm.CalcHiddenGradients extrae el gradiente de la rama Spatial principal y lo prepara para su transmisión hacia abajo.

La rama de corto plazo requiere cuidado: en primer lugar, cShortNorm.CalcHiddenGradients transmite el gradiente de normalización espacial al normalizador de efectos de corto plazo.

//--- Short
   if(!cShortNorm.CalcHiddenGradients(cSpatialNorm.AsObject()))
      return false;
   CBufferFloat* temp = cShortNorm.getGradient();
   if(!cShortNorm.SetGradient(cShortNorm.getPrevOutput(), false) ||
      !cShortNorm.CalcHiddenGradients(cShortExtrapolate.AsObject()) ||
      !SumAndNormilize(temp, cShortNorm.getGradient(), temp, iWindow, false, 0, 0, 0, 1) ||
      !cShortNorm.SetGradient(temp, false))
      return false;
   if(!cShortNorm.GetMeans().CalcHiddenGradients(cShortMeanExtrapolate.AsObject()))
      return false;

Luego debemos obtener el gradiente del error a partir del objeto de extrapolación de datos. Y para no perder los valores obtenidos anteriormente, guardamos el puntero actual al búfer de gradientes de error en la variable local temp. Después viene un truco ingenioso: cambiamos temporalmente el puntero actual del búfer de gradientes del normalizador por un objeto disponible del mismo tamaño. Y a continuación calculamos los gradientes con respecto al módulo de extrapolación. A continuación, sumamos los valores de los dos flujos de información y devolvemos los punteros a los búferes de datos a su estado inicial. Al finalizar la operación del bloque de distribución de gradientes de error de la componente a corto plazo, devolvemos el error procedente del bloque de predicción de medias de la rama a corto plazo a la ruta que las generó.

La rama estacional se procesa siguiendo un esquema similar, pero con una transposición adicional.

//--- Season
   if(!cUnSeasonTransp.CalcHiddenGradients(cShortNorm.AsObject()))
      return false;
   temp = cUnSeasonTransp.getGradient();
   if(!cUnSeasonTransp.SetGradient(cUnSeasonTransp.getPrevOutput(), false) ||
      !cUnSeasonTransp.CalcHiddenGradients(cSeasonExtrapolate.AsObject()) ||
      !SumAndNormilize(temp, cUnSeasonTransp.getGradient(), temp, iWindow, false, 0, 0, 0, 1) ||
      !cUnSeasonTransp.SetGradient(temp, false))
      return false;
   if(!cSeasonNorm.GetMeans().CalcHiddenGradients(cSeasonMeanExtrapolate.AsObject()))
      return false;
   if(!cSeasonNorm.CalcHiddenGradients(cUnSeasonTransp.AsObject()))
      return false;
   if(!cSeasonTransp.CalcHiddenGradients(cSeasonNorm.AsObject()))
      return false;

En primer lugar, cUnSeasonTransp.CalcHiddenGradients devuelve los gradientes de la normalización a corto plazo al bloque de transposición inversa. De nuevo se aplica la técnica del búfer temporal: guardamos en temp, cambiamos el gradiente al búfer libre, calculamos CalcHiddenGradients, acumulamos y devolvemos el resultado a cUnSeasonTransp. Esto garantiza que las aportaciones se sumen correctamente y que las escalas estén alineadas.

A continuación, trasladamos los errores hacia abajo al Seasonal-Normalizer y deshacemos la transposición; es decir, volvemos al formato en el que llegaban los datos desde el normalizador a largo plazo.

La rama a largo plazo contiene el fragmento más delicado: la multiplicación matricial en sentido inverso para las estadísticas. En el método MatMulGrad, realizamos la operación inversa a la multiplicación de vectores de la pasada directa: se calcula la contribución del error al búfer transpuesto cLongMeanSTDevTransp a partir de cLongMeanExtrapolate. De hecho, distribuimos los gradientes entre las medias, teniendo en cuenta la estructura de replicación que utilizamos al extrapolar las medias. Luego enviamos los gradientes obtenidos al módulo de normalización.

//--- Long
   if(!MatMulGrad(cLongMeanSTDevTransp.getOutput(), cLongMeanSTDevTransp.getGradient(),
                  cLongMeanExtrapolate.getPrevOutput(), cLongMeanExtrapolate.getGradient(),
                  cLongMeanExtrapolate.getGradient(), 1, 1, iCount, iWindow, true))
      return false;
   if(!cLongNorm.GetMeanSTDevs().CalcHiddenGradients(cLongMeanSTDevTransp.AsObject()))
      return false;
   if(!cLongNorm.CalcHiddenGradients(cSeasonTransp.AsObject()))
      return false;
   temp = cLongNorm.getGradient();
   if(!cLongNorm.SetGradient(cLongNorm.getPrevOutput(), false) ||
      !cLongNorm.CalcHiddenGradients(cLongExtrapolate.AsObject()) ||
      !SumAndNormilize(temp, cLongNorm.getGradient(), temp, iWindow, false, 0, 0, 0, 1) ||
      !cLongNorm.SetGradient(temp, false))
      return false;

A continuación, cLongNorm.CalcHiddenGradients devuelve los gradientes acumulados de la transposición estacional al normalizador a largo plazo, y volvemos a aplicar la técnica del búfer temporal. Esto permite acumular las aportaciones procedentes de la extrapolación del residuo de la componente a largo plazo y de otros módulos dependientes, sin perder los resultados intermedios.

Por último, la cadena completa de la pasada inversa transmite los gradientes hacia la capa anterior (NeuronOCL). Este es el punto de salida natural para el error: tras todos los recálculos internos, el codificador ha entregado una contribución correctamente formada al eslabón anterior del grafo computacional.

//---
   if(!NeuronOCL.CalcHiddenGradients(cLongNorm.AsObject()))
      return false;
//---
   return true;
  }

A lo largo del método, todas las llamadas están envueltas en una comprobación de resultados. No se trata solo de fiabilidad, sino de una política de fail-fast: detectamos el problema en una fase temprana para evitar inconsistencias sutiles en los búferes que luego serían difíciles de localizar. Técnicas importantes que se repiten a lo largo de todo el método y merecen una atención especial:

  • el uso de búferes de datos temporales para acumular gradientes parciales,
  • el cambio del búfer de gradiente activo para no perder datos,
  • la llamada a SumAndNormilize para combinar correctamente las contribuciones según la dimensión de la ventana.
Todo esto es necesario para garantizar la corrección matemática de la pasada inversa, minimizando las copias innecesarias y teniendo en cuenta las limitaciones de la arquitectura paralela de OpenCL.

En resumen: el método recorre cuidadosamente el orden inverso de la pasada directa, desempaqueta los búferes concatenados, distribuye los gradientes por las ramas, acumula las múltiples fuentes de contribución mediante búferes temporales y normalización, y, al final, devuelve el error al nivel inferior. Este procedimiento garantiza que cada módulo reciba exactamente el gradiente que ha generado y que la corrección total sea coherente y converja de forma estable durante el entrenamiento.

Una vez que se han recopilado los gradientes y se han distribuido cuidadosamente por todas las ramas del codificador, llega la fase final: la actualización propiamente dicha de los parámetros entrenables. En nuestro código, esto se reduce a una sucesión de llamadas a los métodos homónimos de los componentes internos: cada módulo se encarga por sí mismo de aplicar el gradiente acumulado a sus propios pesos.

bool CNeuronSSCNNEncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
//--- Long
   if(!cLongNorm.UpdateInputWeights(NeuronOCL))
      return false;
   if(!cLongExtrapolate.UpdateInputWeights(cLongNorm.AsObject()))
      return false;
//--- Season
   if(!cSeasonNorm.UpdateInputWeights(cSeasonTransp.AsObject()))
      return false;
   if(!cSeasonExtrapolate.UpdateInputWeights(cUnSeasonTransp.AsObject()))
      return false;
   if(!cSeasonMeanExtrapolate.UpdateInputWeights(cSeasonNorm.GetMeans()))
      return false;
//--- Short
   if(!cShortNorm.UpdateInputWeights(cUnSeasonTransp.AsObject()))
      return false;
   if(!cShortExtrapolate.UpdateInputWeights(cShortNorm.AsObject()))
      return false;
   if(!cShortMeanExtrapolate.UpdateInputWeights(cShortNorm.GetMeans()))
      return false;
//--- Spatial
   if(!cSpatialNorm.UpdateInputWeights(cShortNorm.AsObject()))
      return false;
   if(!cSpatialExtrapolate.UpdateInputWeights(cSpatialNorm.AsObject()))
      return false;
   if(!cSpatialMeanExtrapolate.UpdateInputWeights(cSpatialNorm.GetMeans()))
      return false;
//--- Fusion
   if(!cFusion.UpdateInputWeights(cTranspose.AsObject()))
      return false;
//---
   return true;
  }

En definitiva, el método updateInputWeights es un gestor conciso pero potente: simplemente recorre la lista de componentes, delega en ellos la responsabilidad sobre sus propios pesos y recopila cuidadosamente los estados de ejecución. Este enfoque conserva la modularidad, facilita las pruebas y simplifica la ampliación de la arquitectura: al añadir nuevos componentes entrenables, basta con insertar su actualización en el lugar adecuado de esta cadena.

Con esto concluimos el análisis detallado de la lógica de construcción del codificador. El código fuente completo de la clase CNeuronSSCNNEncoder y de todos sus métodos se incluye en el archivo adjunto y puede servir de apoyo para su implementación o modificación.



El objeto de nivel superior

El framework SSCNN se concibió inicialmente como una composición de una cadena de codificadores, cada uno de los cuales profundiza el análisis y aumenta el grado de abstracción de las características. En la práctica, esto permite primero alinear y extraer los componentes evidentes (tendencia, estacionalidad, picos locales) y, a continuación, trabajar sucesivamente con una representación cada vez más rica y compacta, identificando interrelaciones sutiles. Precisamente para gestionar esta secuencia introducimos el objeto de nivel superior CNeuronSSCNN, heredado de CNeuronSCNN.

La clase actúa como gestor: encapsula una secuencia de codificadores, organiza el flujo de tensores entre ellos y resuelve los problemas de compatibilidad de tamaños y formatos, garantizando pasadas directa e inversa correctas a lo largo de toda la cadena.

class CNeuronSSCNN    :  public   CNeuronSCNN
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSSCNN(void) {};
                    ~CNeuronSSCNN(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSSCNN; }
  };

Cabe destacar que el comportamiento de CNeuronSSCNN se ajusta plenamente a la lógica heredada de la clase padre: no estamos inventando un nuevo algoritmo, sino que simplemente especificamos de forma explícita qué codificadores concretos se utilizan en la nueva configuración. El motivo para redefinir la clase es puramente pragmático: es necesario fijar un tipo concreto de codificador y sus parámetros para construir de forma secuencial varios niveles. En los nuevos métodos solo realizamos cambios puntuales: sustituimos los tipos y formatos de los codificadores utilizados, sin alterar la propia lógica de cálculo. Por lo tanto, ahora no tiene sentido analizar en detalle los algoritmos que ya se han tratado anteriormente: su implementación sigue siendo la misma. El código fuente completo de la clase y de todos sus métodos está disponible en el archivo adjunto.



Entrenamiento del modelo

Unas palabras sobre el proceso de entrenamiento del modelo. Mantenemos una estrategia en dos fases: primero, un entrenamiento offline intensivo; después, un ajuste fino online en modo operativo. Para acelerar la etapa offline, simplificamos deliberadamente la representación del estado del entorno en el codificador de estado del entorno y excluimos el entrenamiento de los módulos predictivos auxiliares; esto aporta una mejora notable en la velocidad de preparación, pero reduce la representatividad de la representación inicial. El proceso de entrenamiento se lleva a cabo mediante el método Train.

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);

En primer lugar, el método calcula los índices de inicio y fin de la ventana de entrenamiento y, a continuación, carga los datos históricos desde el terminal. Se trata de una preparación de datos estándar: necesitamos los precios brutos y las marcas de tiempo a partir de los cuales se construirán todas las características y muestras posteriores.

   if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) ||
      !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      ExpertRemove();
      return;
     }

El siguiente bloque comprueba si se ha podido redimensionar los búferes de los indicadores. Si no se ha podido asignar al menos un búfer, mostramos un mensaje de depuración y finalizamos correctamente el asesor experto (ExpertRemove) para no continuar en un estado incoherente.

A continuación, se inicia el ciclo de espera para el cálculo de los indicadores. Comprobamos durante un número limitado de iteraciones si los indicadores ya han sido calculados. Esto evita situaciones en las que los datos aún no estén listos: en lugar de empezar con datos incompletos, le damos tiempo al sistema, pero no indefinidamente. Si se supera el límite, mostramos un mensaje y finalizamos la ejecución.

//---
   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;
     }

Acto seguido, actualizamos los indicadores mediante el método Refresh() y configuramos el array Rates en modo Series para que el acceso por índices siga el orden habitual de las series temporales en MQL5 para los datos históricos. A continuación, ajustamos el número de barras disponibles. Esto tiene en cuenta que, para cada ejemplo de entrenamiento, necesitamos HistoryBars valores anteriores y una reserva para la predicción NForecast. Si el resultado es inferior a cero, es una señal de que no hay suficientes datos y detenemos el proceso.

Preparamos los vectores de trabajo y la bandera de parada anticipada. También anotamos la hora actual; esto será necesario para actualizar periódicamente la UI y controlar el tiempo.

   vector<float> result, target, neg_target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

El ciclo principal de entrenamiento recorre las épocas (epoch). Se ejecuta hasta que se alcance el límite Epochs, se llame a IsStopped (parada externa) o se active la bandera Stop debido a un error interno. Al comienzo de cada época, limpiamos el estado interno del codificador.

   for(int epoch = 0; (epoch < Epochs && !IsStopped() && !Stop); epoch ++)
     {
      if(!cEncoder.Clear())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         return;
        }

Dentro de la época se ejecuta un ciclo por las posiciones posit, que avanza desde el inicio hasta el final de la ventana histórica. Para cada posición, primero preparamos los datos de entrada mediante la llamada a CreateBuffers: esto genera los búferes State y Time, que posteriormente se envían a la red. A continuación, formamos el vector de estado de la cuenta y generamos la acción de referencia.

for(int posit = start - HistoryBars - NForecast - 1; posit >= end; posit--)
  {
   if(!CreateBuffers(posit, GetPointer(bState), GetPointer(bTime), Result))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      ExpertRemove();
      return;
     }
   const vector<float> account = SampleAccount(GetPointer(bState), datetime(bTime[0]));
   const vector<float> target_action = OraculAction(account, Result);
   if(!bAccount.AssignArray(account))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      ExpertRemove();
      return;
     }

Utilizamos un entrenamiento híbrido, es decir, partiendo de las señales de recompensa y de una trayectoria de referencia con acceso a información futura.

A continuación comienza la pasada directa. En primer lugar, el codificador del estado del entorno genera una representación del estado actual del mercado.

   //--- Feed Forward
   if(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bTime)))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }

Luego Actor genera la acción del agente. A su vez, Critic evalúa la calidad de la acción en el estado actual.

   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;
     }

Todas las llamadas están protegidas por comprobaciones: en caso de error, interrumpimos de inmediato y activamos el indicador Stop.

El bloque Study es el paso principal de actualización basada en la recompensa. Luego obtenemos la acción del agente y calculamos la recompensa. Un detalle interesante: si reward es negativo, lo multiplicamos por 2, es decir, aumentamos las penalizaciones por las malas decisiones. Este refuerzo acelera el entrenamiento a partir de los errores, pero hay que actuar con precaución: una penalización demasiado severa puede desestabilizar el entrenamiento.

//--- Study
cActor.getResults(Action);
double equity = bAccount[2] * bAccount[0] * EtalonBalance / (1 + bAccount[1]);
double reward = CheckAction(Action, equity, posit - NForecast + 1) / EtalonBalance;
if(reward < 0)
   reward *= 2;
Result.Clear();
if(!Result.Add(float(reward)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cCritic.backProp(Result, GetPointer(cEncoder), LatentLayer) ||
   !cEncoder.backPropGradient((CBufferFloat*)NULL, NULL, LatentLayer, true) ||
   !cActor.backPropGradient(GetPointer(cEncoder), LatentLayer, -1, true)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

A continuación, realizamos una pasada inversa secuencial: primero Critic, luego Encoder y, por último, Actor. Se trata del esquema clásico Actor-Critic: el crítico se actualiza según el error de estimación del valor, y a continuación la señal se transmite al actor y al codificador. Aquí es importante tener en cuenta el orden: Critic -> Encoder -> Actor, para que los gradientes se propaguen correctamente por la estructura compartida del grafo.

Después viene la etapa Oracul: el entrenamiento con la trayectoria de referencia. Volvemos a realizar una pasada directa a través del codificador y del actor, ya que poco antes hemos actualizado sus parámetros y, como era de esperar, el resultado del análisis de ese mismo estado del entorno será diferente.

//--- Oracul
if(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bTime)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cActor.feedForward(GetPointer(bAccount), 1, false, GetPointer(cEncoder), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!Action.AssignArray(target_action))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
reward = CheckAction(Action, equity, posit - NForecast + 1) / EtalonBalance;

A continuación, sustituimos la acción del agente por la acción de referencia y calculamos su recompensa. Después de esto, realizamos la pasada inversa del Actor, es decir, lo entrenamos para imitar la referencia.

if(!cActor.backProp(Action, GetPointer(cEncoder), LatentLayer))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cCritic.feedForward(Action, 1, false, GetPointer(cEncoder), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!Result.Update(0, float(reward)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cCritic.backProp(Result, GetPointer(cEncoder), LatentLayer) ||
   !cEncoder.backPropGradient((CBufferFloat*)NULL, NULL, LatentLayer, true))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
//---
if(GetTickCount() - ticks > 500)
  {
   double percent = (epoch + 1.0 - double(posit - end) / (start - end - HistoryBars - NForecast)) /
                                                                                    Epochs * 100.0;
   string str = "";
   str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Actor", percent,
                                                                    cActor.getRecentAverageError());
   str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Critic", percent, 
                                                                   cCritic.getRecentAverageError());
   Comment(str);
   ticks = GetTickCount();
  }
 }
}

Acto seguido, ejecutamos Critic sobre la acción de referencia, realizando la pasada directa y la pasada inversa. A continuación, volvemos a ejecutar la optimización de los parámetros del codificador. De este modo, en cada paso aprendemos primero a partir de nuestra propia experiencia (Reinforcement) y, a continuación, ajustamos la política para acercarla a la referencia (Supervised). Esto suele proporcionar una convergencia más estable y rápida.

Durante el bucle, cada ~500 ms, generamos una cadena de estado con el porcentaje de progreso y los errores actuales del Actor y del Crítico. Se trata de un mecanismo importante para informar al usuario sobre el progreso del entrenamiento.

Una vez completadas todas las épocas, limpiamos el comentario y escribimos las métricas finales en el registro del terminal; después, finalizamos el programa de forma segura.

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", cActor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", cCritic.getRecentAverageError());
   ExpertRemove();
//---
  }

El código completo del programa de entrenamiento offline se incluye en el archivo adjunto. Allí mismo encontrará el código de todos los programas utilizados en la preparación del artículo, así como la arquitectura de los modelos entrenables, que se han trasladado desde el trabajo anterior prácticamente sin modificaciones. En la arquitectura del codificador, solo hemos cambiado el tipo de la capa principal del codificador.


Pruebas

Como ya se ha mencionado anteriormente, el proceso de entrenamiento consta de dos etapas consecutivas. En la primera fase, la fase offline, el modelo se entrenó con datos históricos del par EURUSD con timeframe H1 durante todo el año 2024. Este periodo abarcó una amplia gama de escenarios de mercado —desde fases tranquilas hasta picos bruscos— y permitió que el modelo se familiarizara tanto con situaciones típicas como con otras poco frecuentes.

En la segunda fase, llevamos a cabo un ajuste fino online en condiciones lo más cercanas posible al mercado real. El entrenamiento se llevó a cabo en el simulador de estrategias de MetaTrader 5, donde el modelo procesaba de forma secuencial un flujo en vivo de velas. Este modo permite evaluar la robustez frente al ruido y las distorsiones, y ofrece la posibilidad de adaptar el comportamiento ante cambios en el contexto del mercado.

Por último, probamos el modelo con datos totalmente nuevos: las cotizaciones correspondientes al periodo comprendido entre enero y marzo de 2025. Todos los parámetros y ajustes se mantuvieron sin cambios. Los resultados obtenidos ofrecen una visión objetiva de la precisión y la fiabilidad práctica del enfoque propuesto. A continuación se muestran los resultados de las pruebas.

Resultados de las pruebas Resultados de las pruebas

Los resultados de las pruebas muestran una dinámica moderadamente positiva. Partiendo de un depósito de 100 dólares, el asesor experto logró obtener una ganancia de 62,25 dólares, lo que supuso una ligera diferencia positiva entre el beneficio total y las pérdidas totales. Al mismo tiempo, el factor de beneficio apenas supera 1, lo que indica una baja eficacia de la estrategia.

Durante el periodo de prueba se realizaron 898 operaciones, repartidas casi a partes iguales entre compras y ventas. En ambos casos, el porcentaje de operaciones rentables se mantuvo en torno al 50 %, con un ligero predominio de las posiciones cortas. Una operación rentable generaba de media 1,97 dólares, mientras que una operación con pérdidas suponía una pérdida de 1,82 dólares. La ganancia máxima alcanzó los 13,56 dólares, mientras que la pérdida máxima fue de 12,42 dólares.

En general, el asesor experto muestra un funcionamiento estable con una ejecución precisa de las operaciones, pero la rentabilidad y la robustez de la estrategia siguen siendo cuestionables.



Conclusión

Este artículo culmina el trabajo de implementación, mediante herramientas de MQL5, de los enfoques propuestos por los autores del framework SSCNN. El lector se ha familiarizado con la idea de la integración espaciotemporal de características, en la que cada instante temporal refleja las complejas interrelaciones entre las estructuras internas de los datos y los impulsos externos del mercado.

Este modelo no solo amplía los límites de la comprensión del comportamiento del mercado, sino que también ofrece una herramienta para adaptarse a los cambios de modo del mercado, desde fases tranquilas hasta cambios bruscos en la volatilidad. La incorporación de Attention-based Normalization refuerza el enfoque en las zonas relevantes y estabiliza el entrenamiento, convirtiendo SSCNN en un mecanismo analítico flexible, capaz de desentrañar la complejidad estructural de las series financieras.

Enlaces


Programas utilizados en el artículo

# Nombre Tipo Descripción
1 Study.mq5 Asesor experto Asesor experto para el entrenamiento offline de modelos
2 StudyOnline.mq5 Asesor experto Asesor experto para el entrenamiento online de modelos
3 Test.mq5 Asesor experto Asesor experto para probar el modelo
4 Trajectory.mqh Biblioteca de clases Estructura de descripción del estado del sistema y de la arquitectura de los modelos
5 NeuroNet.mqh Biblioteca de clase 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/19134

Archivos adjuntos |
MQL5.zip (2986.56 KB)
Implementación de módulos prácticos de otros lenguajes en MQL5 (Parte 02): Desarrollo de la biblioteca REQUESTS inspirada en requests de Python Implementación de módulos prácticos de otros lenguajes en MQL5 (Parte 02): Desarrollo de la biblioteca REQUESTS inspirada en requests de Python
En este artículo, implementamos un un módulo similar a requests, como el que ofrece Python para facilitar el envío y la recepción de solicitudes web en MetaTrader 5 utilizando MQL5.
Herramientas de trading de MQL5 (Parte 4): Mejorando el panel del escáner multitemporal con posicionamiento dinámico y funciones de alternancia Herramientas de trading de MQL5 (Parte 4): Mejorando el panel del escáner multitemporal con posicionamiento dinámico y funciones de alternancia
En este artículo, actualizamos el panel de control del escáner multitemporal MQL5 con funciones de arrastre y alternancia. Hemos habilitado la opción de arrastrar el panel de control y una opción para minimizar/maximizar que permite un mejor uso de la pantalla. Implementamos y probamos estas mejoras para lograr una mayor flexibilidad operativa en el trading.
Algoritmo del duelista — Duelist Algorithm Algoritmo del duelista — Duelist Algorithm
¿Y si nuestras estrategias de trading pudieran aprender unas de otras, como auténticos luchadores? Duelist Algorithm es un nuevo método de optimización en el que los parámetros de los sistemas de trading se enfrentan literalmente en duelos por el derecho a ser considerados los mejores.
Redes neuronales en el trading: descomposición en lugar de escalado — Construcción de módulos Redes neuronales en el trading: descomposición en lugar de escalado — Construcción de módulos
En este artículo continuamos la familiarización práctica con SSCNN: una solución arquitectónica de nueva generación capaz de trabajar con series temporales fragmentadas. En lugar de un escalado indiscriminado: modularidad inteligente, atención al detalle y normalización selectiva. Paso a paso, creamos bloques computacionales en el entorno MQL5 y sentamos las bases para un análisis predictivo fiable.