English Русский 中文 Deutsch 日本語
preview
Características del Wizard MQL5 que debe conocer (Parte 68): Uso de patrones de TRIX y Williams Percent Range con una red de núcleo coseno

Características del Wizard MQL5 que debe conocer (Parte 68): Uso de patrones de TRIX y Williams Percent Range con una red de núcleo coseno

MetaTrader 5Integración |
20 0
Stephen Njuki
Stephen Njuki

Introducción

De los diez patrones de señal que analizamos en el último artículo, solo tres superaron la prueba walk-forward. Estos patrones se generaron a partir de la combinación de las señales del TRIX, un indicador de tendencia, y del Williams Percent Range (WPR), un oscilador de soporte y resistencia. El entrenamiento y la optimización del asesor experto se limitaron a un solo año, el 2023, y la prueba walk-forward se llevó a cabo durante el año siguiente, el 2024. Estábamos realizando pruebas con el par CHF/JPY en el gráfico de 4 horas.

Al ampliar con aprendizaje automático los patrones que superaron la validación walk-forward, solemos utilizar Python, ya que permite programar y entrenar redes de forma muy eficiente. Esto es así incluso sin una GPU. En artículos anteriores, hemos venido presentando implementaciones en Python de funciones correspondientes a patrones que superaron la validación walk-forward. En este artículo, abordaremos brevemente la implementación de los indicadores en Python, pero nos centraremos principalmente en la configuración de la red que toma como entradas las señales de los indicadores. Se trata de una red convolucional unidimensional que utiliza el núcleo coseno en su diseño.


Indicadores en Python

Para utilizar señales de indicadores en Python, en el caso de nuestra red, podemos recurrir a algunas bibliotecas de código de Python o podemos programarlo nosotros mismos. Programamos nuestra función TRIX en Python de la siguiente manera:

def TRIX(df: pd.DataFrame, period: int) -> pd.DataFrame:
    """
    Calculate TRIX indicator and append it as 'TRIX' column to the input DataFrame.
    
    Args:
        df (pd.DataFrame): DataFrame with 'close' column
        period (int): Lookback period for EMA calculation
        
    Returns:
        pd.DataFrame: Input DataFrame with new 'TRIX' column
    """
    # Input validation
    if not all(col in df.columns for col in ['close']):
        raise ValueError("DataFrame must contain 'close' column")
    if period < 1:
        raise ValueError("Period must be positive")
        
    # Create a copy to avoid modifying the input DataFrame
    result_df = df.copy()
    
    # Calculate triple EMA
    ema1 = df['close'].ewm(span=period, adjust=False).mean()
    ema2 = ema1.ewm(span=period, adjust=False).mean()
    ema3 = ema2.ewm(span=period, adjust=False).mean()
    
    # Calculate TRIX: percentage rate of change of triple EMA
    result_df['main'] = ema3.pct_change() * 100
    
    return result_df

El TRIX calcula la tasa de variación de la media móvil exponencial (EMA) con triple suavización. Nuestros datos de entrada son un dataframe de pandas con una columna «close» y un período entero para los cálculos de la media móvil exponencial (EMA). El resultado es el mismo dataframe, al que se le ha añadido una columna con los valores de los indicadores. Se utiliza principalmente para identificar la dirección de la tendencia y posibles cambios de tendencia, suavizando los datos de precios y poniendo de relieve los cambios en el impulso.

El código anterior comienza definiendo la función con indicaciones de tipo para sus parámetros de entrada, lo que garantiza la claridad y la seguridad de tipos. A continuación, calculamos la primera media móvil exponencial (EMA) a partir de los precios de cierre y, después, suavizamos estos datos de precios para que la ponderación de la EMA sea coherente. A continuación, calculamos la segunda media móvil exponencial (EMA) sobre la primera EMA para suavizar aún más los datos y reducir el ruido. Esto asigna el valor a ema2. A continuación, calculamos la tercera media móvil exponencial (EMA), con lo que completamos el proceso de triple suavizado. Además, hace que TRIX sea sensible a los cambios en el impulso. El resultado que obtenemos es el valor de ema3.

De este modo, añadimos a nuestro dataframe pandas de entrada un cálculo de TRIX que se expresa como la variación porcentual de la media móvil exponencial triple (ema3) con respecto a su valor anterior. La multiplicación por 100 escala el resultado para expresarlo en términos porcentuales y facilitar su interpretación. Una vez definido esto, pasamos a analizar el Williams Percent Range (WPR). Esto se programa de la siguiente manera en Python:

def WPR(df: pd.DataFrame, period: int) -> pd.DataFrame:
    """
    Calculate Williams %R indicator and append it as 'WPR' column to the input DataFrame.
    
    Args:
        df (pd.DataFrame): DataFrame with 'high', 'low', 'close' columns
        period (int): Lookback period for calculation
        
    Returns:
        pd.DataFrame: Input DataFrame with new 'WPR' column
    """
    # Input validation
    if not all(col in df.columns for col in ['high', 'low', 'close']):
        raise ValueError("DataFrame must contain 'high', 'low', 'close' columns")
    if period < 1:
        raise ValueError("Period must be positive")
        
    # Create a copy to avoid modifying the input DataFrame
    result_df = df.copy()
    
    # Calculate highest high and lowest low over the period
    high_max = df['high'].rolling(window=period).max()
    low_min = df['low'].rolling(window=period).min()
    
    # Calculate Williams %R
    result_df['main'] = ((high_max - df['close']) / (high_max - low_min)) * -100
    
    return result_df

El WPR es un oscilador de soporte y resistencia que ayuda a determinar si el precio se encuentra en sobrecompra o en un nivel de resistencia, o bien en sobreventa o en un nivel de soporte. Los datos de entrada de nuestra función para realizar este cálculo también son un dataframe de pandas, pero con las columnas «high», «low» y «close», además de un período entero para determinar la ventana retrospectiva del indicador. El resultado es también un dataframe de pandas con una columna añadida que denominamos WPR. Contiene valores comprendidos entre [-100, 0].

Nuestro código comienza con indicaciones de tipo para los datos de entrada, al igual que en TRIX. A continuación, calculamos el máximo más alto registrado durante el periodo especificado, lo que nos permite establecer un límite superior para el cálculo del WPR. A continuación, calculamos de forma similar el mínimo más bajo durante el mismo período de referencia. A continuación, definimos el buffer adicional que vamos a añadir al final de nuestro dataframe de pandas de entrada, y al que denominamos «WPR». Para ello se utiliza la fórmula estándar del WPR. 

La capacidad de Python para convertir nuestros cálculos en búferes sin ningún esfuerzo es algo a lo que los programadores que, como yo, proceden de lenguajes tipo C, tenemos que acostumbrarnos. Es increíble.

Se puede introducir una validación adicional para ambas funciones, ya que estas dan por hecho que el dataframe de entrada cuenta con las columnas necesarias y un número suficiente de filas de datos. Estamos utilizando una implementación de pandas de los datos del módulo de Python de MetaTrader 5. Sin embargo, añadir un tratamiento de errores —no necesariamente para las columnas que faltan, ya que MetaTrader 5 siempre cuenta con todas sus columnas, sino para el posible número reducido de filas de datos— puede mejorar ambas funciones. Además, en el caso de conjuntos de datos de gran tamaño, la optimización mediante el cálculo previo de ventanas móviles o el uso de operaciones vectorizadas (tal y como las implementa pandas) es un aspecto que debería ser prioritario. A la hora de realizar pruebas, la validación de los resultados comparándolos con los valores de los indicadores conocidos de plataformas como MetaTrader 5 también puede ayudar a garantizar la precisión.


Ventajas del uso del núcleo coseno en la arquitectura Conv1D

Una red Conv1D es una red neuronal convolucional especial que utiliza operaciones de convolución unidimensionales sobre datos en secuencia, como series temporales financieras o texto convencional. En este proceso, aplica filtros a los datos de entrada para extraer patrones, tendencias o motivos importantes de dichos datos. El resultado de esta extracción reduce, por naturaleza, las dimensiones de la secuencia de entrada. Sin embargo, cada filtro está diseñado para extraer una característica específica o importante de los datos de entrada. La red incluiría capas de convolución, de activación, de agrupación y capas totalmente conectadas. El Conv1D resulta eficaz para procesar datos ordenados que presentan diferencias temporales o secuenciales.

El núcleo coseno, por su parte, es una medida de similitud. Calcula el coseno del ángulo entre dos vectores dados, cuantificando en qué medida sus direcciones son similares. Para ello, normaliza el producto escalar de los dos vectores por sus magnitudes. Los valores de salida oscilan entre -1 —lo que significa que los dos vectores apuntan en direcciones opuestas— y +1 —lo que significa que los dos vectores están alineados y apuntan en la misma dirección—. Esta similitud puede resultar muy eficaz a la hora de trabajar con datos de alta dimensión, como el texto, en los que las magnitudes no son tan importantes como la orientación.

El uso de un núcleo coseno en el diseño de una red Conv1D presenta algunas ventajas. En primer lugar, permite variar de forma gradual el tamaño de los núcleos. La función coseno genera un patrón oscilatorio suave para los tamaños de los núcleos, lo que permite a la CNN extraer características a diferentes escalas sin cambios bruscos. Otra ventaja es el ajuste adaptativo del número de canales, ya que el escalado de canales basado en el coseno permite aumentar gradualmente el número de filtros. Esto permite equilibrar la complejidad del modelo y la capacidad de extracción de características en todas las capas.

El núcleo coseno también permite una extracción robusta de características. Esto se debe a su naturaleza oscilatoria, que imita los patrones naturales de las series temporales con retorno a la media, como las series temporales financieras. Esto puede ayudar a la red neuronal convolucional (CNN) a detectar patrones periódicos o cíclicos. Por último, el núcleo coseno introduce un efecto de regularización debido a la variación en el tamaño de sus núcleos y canales. Esta «regularización» reduce el riesgo de sobreajuste al introducir una variabilidad controlada en la arquitectura.

El uso de un núcleo coseno con una capa Conv1D suele requerir que los datos de entrada sean un tensor 3D con las dimensiones batch_size, input_channels e input_length. Dado que estamos utilizando series temporales univariantes, el valor de nuestros canales de entrada es 1. Una longitud de entrada suficiente permite gestionar fácilmente los tamaños de los núcleos sin que se produzca una reducción excesiva. 

En este caso, los hiperparámetros clave que hay que ajustar en nuestro modelo son cinco. Lo primero es el «base_channels». Lo ideal sería que, al principio, el número fuera reducido, de unos 16 o 32 como máximo. El número de capas, el segundo parámetro, suele estar comprendido entre 3 y 5. Un número excesivo de capas provoca el problema del gradiente que se desvanece o un número excesivo de cálculos. El tercer parámetro importante es el tamaño máximo del núcleo, que puede fijarse en 7 o más para secuencias más largas. Debería ser impar para mantener la simetría con el padding.

El siguiente parámetro es la frecuencia, que controla la oscilación de los tamaños de los núcleos y de los canales. Un valor de 0,5 indica una oscilación moderada, mientras que se pueden realizar ajustes dentro del intervalo de 0,1 a 1,0 para conseguir oscilaciones más rápidas o más lentas, respectivamente. El último hiperparámetro importante es la tasa de dropout. Este valor puede establecerse en un intervalo comprendido entre 0,2 y 0,5, y es importante para la regularización. Aunque unos valores de dropout más altos reducen el sobreajuste, sí que afectan al resultado de la función de pérdida, ya que esta tiene dificultades para alcanzar su límite ideal de cero.

Durante el entrenamiento, podemos utilizar optimizadores estándar, como Adam, y un programador de la tasa de aprendizaje para lograr una convergencia más rápida y precisa. Una vez más, es fundamental aplicar una normalización por lotes y un «dropout» adecuados para evitar el sobreajuste, sobre todo cuando se trabaja con conjuntos de datos pequeños. Lo ideal es que la salida, cuando se utiliza la similitud coseno para dimensionar los núcleos de las capas, sea binaria, para la clasificación; es decir, una sola neurona con activación sigmoide. Las capas totalmente conectadas deberían modificarse para otras tareas, como la clasificación multiclase o la regresión. Los casos de uso son, idealmente, datos de series temporales en los que se esperan patrones periódicos u oscilatorios.

Las limitaciones del uso de este núcleo en el diseño de una CNN residen en que puede que no resulte óptimo para todos los conjuntos de datos. Es importante realizar pruebas con arquitecturas Conv1D estándar para garantizar que el rendimiento esté a la altura. Además, la capa de agrupación promedio global (global average pooling) asume un tamaño de salida fijo. Es posible que esto no sea adecuado para tareas que requieran salidas secuenciales.


La red

Nuestra red, que toma las señales de TRIX y WPR como vector de entrada binario, es una red neuronal convolucional que utiliza la similitud coseno para determinar el tamaño de sus núcleos, tal y como se ha explicado anteriormente. Lo programamos de la siguiente manera:

class CosineConv1D(nn.Module):
    """
    A 1D Convolutional Neural Network with kernel sizes and channels based on cosine functions.
    Outputs a scalar float in [0,1] using sigmoid activation.
    """
    def __init__(self, input_channels: int, base_channels: int, num_layers: int, input_length: int,
                 max_kernel_size: int = 7, frequency: float = 0.5, dropout_rate: float = 0.3):
        super(CosineConv1D, self).__init__()
        
        if input_channels < 1 or base_channels < 1 or num_layers < 1:
            raise ValueError("Input channels, base channels, and num layers must be positive")
        if input_length < 1:
            raise ValueError("Input length must be positive")
        if max_kernel_size < 1:
            raise ValueError("Max kernel size must be positive")
        if not (0 <= dropout_rate < 1):
            raise ValueError("Dropout rate must be between 0 and 1")
        
        self.layers = nn.ModuleList()
        self.input_length = input_length
        current_length = input_length
        
        for i in range(num_layers):
            kernel_size = int(3 + (max_kernel_size - 3) * (1 + np.cos(2 * np.pi * frequency * i)) / 2)
            kernel_size = max(3, min(kernel_size, max_kernel_size))
            channels = int(base_channels * (1 + 0.5 * np.cos(np.pi * i / num_layers)))
            channels = max(base_channels, channels)
            padding = kernel_size // 2
            
            conv_layer = nn.Sequential(
                nn.Conv1d(
                    in_channels=input_channels if i == 0 else self.layers[-1][0].out_channels,
                    out_channels=channels,
                    kernel_size=kernel_size,
                    padding=padding
                ),
                nn.BatchNorm1d(channels),
                nn.ReLU(),
                nn.Dropout(dropout_rate)
            )
            self.layers.append(conv_layer)
            current_length = (current_length - kernel_size + 2 * padding) // 1 + 1
            
        self.global_pool = nn.AdaptiveAvgPool1d(1)
        self.fc = nn.Sequential(
            nn.Linear(channels, 1),
            nn.Sigmoid()
        )
        
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        for layer in self.layers:
            x = layer(x)
        x = self.global_pool(x)
        x = x.squeeze(-1)
        x = self.fc(x)
        return x

    def get_output_length(self) -> int:
        return self.layers[-1][0].out_channels

A modo de visión general o resumen de esta red, esta CNN unidimensional utiliza tamaños de kernel y canales modulados por coseno, y genera un valor escalar en el intervalo [0,1] mediante una función de activación sigmoide. Utiliza un módulo personalizado de PyTorch que hereda del módulo «nn». Esta arquitectura, basada en la modulación del tamaño de los núcleos y del número de canales, permite que se adapte a los patrones de entrada. La función de activación sigmoide garantiza que la salida sea un valor escalar que representa una probabilidad, comprendido entre 0 y 1.

La inicialización de la red se realiza junto con la validación. Esto garantiza que los parámetros de entrada relativos al número de canales, capas, tamaño del núcleo y tasa de dropout sean válidos. Las comprobaciones principales en este caso son que los valores de entrada sean positivos y que la tasa de abandono se encuentre en el intervalo de 0 a 1. Esto es fundamental para evitar errores de configuración que puedan provocar errores en tiempo de ejecución o un rendimiento deficiente. Además, aporta flexibilidad al diseño de la red gracias al uso de estos parámetros personalizables. 

Al implementar esto, lo habitual es configurar los canales de entrada para que se ajusten a las dimensiones de los datos de entrada. Los canales base se utilizan para controlar la «capacidad del modelo», mientras que el número de capas permite equilibrar la profundidad con los requisitos de cálculo. El tamaño y la frecuencia máximos del núcleo se pueden ajustar para optimizar la extracción de características en cada etapa de la pasada hacia adelante.

Una vez completadas la inicialización y la validación, pasamos al dimensionamiento del núcleo y al escalado de canales. Como ya se ha mencionado anteriormente, utilizamos funciones coseno para ajustar dinámicamente el tamaño de los núcleos y el número de canales en cada capa. Por lo tanto, calculamos el tamaño del núcleo y el número de canales para cada capa utilizando las funciones coseno. El tamaño del núcleo variaría entre 3 y el valor del parámetro de tamaño máximo del núcleo. El número de canales se incrementa a partir del valor base. Además, queda modulado por la función coseno.

Todo esto es importante porque introducimos una variación dinámica tanto en los campos receptivos como en la capacidad de características, lo que permite a la red captar patrones diversos. La modulación coseno garantiza transiciones suaves entre los núcleos, evitando así cambios bruscos. Durante la implementación, ajustamos la frecuencia, normalmente en el intervalo de 0,1 a 1,0, con el fin de controlar la oscilación del tamaño del núcleo. El uso de canales de base más altos suele ser adecuado para conjuntos de datos más complejos o multidimensionales. Siempre es importante asegurarse de que el parámetro de tamaño máximo del núcleo se ajuste a la longitud de los datos de entrada, con el fin de evitar un relleno excesivo. Con esto, pasamos a la construcción de la capa convolucional. Esto se consigue mediante la normalización por lotes, la función ReLU y el uso de «dropouts» para garantizar la robustez.

Construimos cada capa como una secuencia formada por una convolución unidimensional, una normalización por lotes, una activación ReLU y un dropout. La primera capa utiliza canales de entrada. Las capas siguientes utilizan los canales de salida de la capa anterior. El relleno ayuda a mantener la longitud de la entrada. Este enfoque es importante porque cada uno de los componentes de estas capas desempeña un papel fundamental. La convolución se utiliza para la extracción de características. Se ha añadido la normalización por lotes para garantizar la estabilidad del entrenamiento. La función de activación ReLU contribuye a garantizar la no linealidad. Y, por último, el «dropout» contribuye a la regularización con el fin de evitar el sobreajuste. A la hora de implementar el modelo, puede ser una buena idea utilizar una tasa de eliminación comprendida entre 0,2 y 0,5 para gestionar el riesgo de sobreajuste. Es importante asegurarse de que los canales de entrada se ajusten a los datos y de que el módulo «nn.ModuleList» pueda adaptarse para la gestión dinámica de capas. A continuación, nos encargamos de la agrupación global y de la salida.

Esto aplica un agrupamiento promedio adaptativo para reducir la dimensión espacial a 1. A continuación, se aplica una capa lineal que mapea a una única salida y una función sigmoide para limitarla al intervalo de 0 a 1. Esto es importante porque la agrupación resume las características a lo largo de toda la secuencia, lo que permite obtener una salida de tamaño fijo independientemente de la longitud de la entrada. La capa lineal y la sigmoide generan un valor escalar para tareas como la clasificación. En nuestro caso, utilizamos esta red para una única salida. Por regla general, siempre que se dé este caso, hay que asegurarse de que los canales de la capa final coincidan con la entrada de la capa lineal.

A continuación, definimos nuestra función de paso hacia adelante. Esta función esencial procesa los datos de entrada a través de distintas capas, mediante la agrupación de datos y la transformación final de los datos de salida. Define la propagación hacia adelante de la red que procesa la entrada x. Esto se lleva a cabo mediante capas convolucionales, un proceso de pooling global que reduce la dimensión espacial y, finalmente, a través de la capa totalmente conectada. Esta función es importante porque especifica el flujo de datos. Esto garantiza que se realicen las transformaciones correctas desde la entrada hasta la salida. Al comprimir la dimensión espacial adicional se omite la dimensión única para garantizar la compatibilidad con la capa lineal. La implementación debe garantizar que la forma del tensor sea correcta. Para solucionar los problemas de desajuste de formas, basta con comprobar las salidas de las capas.

Por fin tenemos una función para controlar la longitud de la salida. Este seguimiento garantiza la compatibilidad y también puede utilizarse en la depuración, tal y como se ha señalado anteriormente. Devuelve el número de canales de salida de la capa convolucional final. Esto proporciona metadatos importantes sobre la salida de la red, lo cual resulta útil para tareas posteriores o para la depuración. Esta función también se puede utilizar para comprobar la compatibilidad con capas o modelos posteriores. Además, es ampliable en caso de que se necesiten más metadatos, como datos, etc.


Secuencias y entrenamiento

También disponemos de una función para crear secuencias destinadas al preprocesamiento de datos para nuestra red. Esta función sirve para organizar los datos de entrada y las etiquetas en secuencias para su procesamiento por una CNN 1D. Esto se programa en Python de la siguiente manera:

def create_sequences(data, labels, sequence_length):
    num_samples, num_features = data.shape
    sequences = []
    seq_labels = []
    
    # Ensure labels is 1D
    labels = labels.flatten()
    
    for i in range(num_samples - sequence_length + 1):
        sequences.append(data[i:i+sequence_length].T)  # Transpose to (num_features, sequence_length)
        seq_labels.append(labels[i+sequence_length-1])  # Use label of last sample in sequence
    
    sequences = np.array(sequences)  # Shape: (num_sequences, num_features, sequence_length)
    seq_labels = np.array(seq_labels).reshape(-1, 1)  # Shape: (num_sequences, 1)
    
    return torch.tensor(sequences, dtype=torch.float32), torch.tensor(seq_labels, dtype=torch.float32)

Al crear secuencias, lo primero que hacemos es generar aquellas cuya longitud coincida con el parámetro de entrada «sequence-length», a partir de los datos de entrada con la estructura [muestras, características], así como de las etiquetas correspondientes. A continuación, estos datos se transponen para que se ajusten al formato de entrada de la CNN, que es [características, longitud de la secuencia]. Las etiquetas se obtienen del último paso temporal de cada secuencia. Esto es importante porque prepara los datos para una CNN 1D, estructurándolos en tramos de datos relacionados a los que nos referimos como «secuencias». Esto cumple con los requisitos de compatibilidad con nuestra red «CosineConv1D» mencionada anteriormente.

La asignación de la longitud de la secuencia se basa en las dependencias temporales, es decir, la distancia de retraso a la que los datos —por ejemplo, en una serie temporal— presentan patrones repetibles y rastreables. Es fundamental asegurarse de que el número de características coincida con el número de canales de entrada. La alineación de las etiquetas también deberá verificarse en el caso de las tareas supervisadas con conjuntos de datos complejos.

Una vez definida la función «create sequences», vamos a ver ahora nuestras funciones «train» y «evaluate». Lo primero que hacemos aquí es configurar los hiperparámetros. En la primera sección de la función, que consta de cuatro líneas de código, definimos los hiperparámetros de entrenamiento. Lo que tenemos que configurar son el tamaño de los lotes para el procesamiento por minilotes; los canales de entrada para las condiciones largas y cortas; la longitud de la secuencia, el número de épocas y la tasa de aprendizaje. La longitud de nuestra secuencia está fijada en 1, lo que implica una dependencia temporal mínima en la secuencia. El texto asume, no obstante, relaciones intrapatrón asociadas a una escala temporal semanal. Programamos nuestra función de entrenamiento de la siguiente manera:

def train_and_evaluate(x_train, y_train):
    # Hyperparameters
    batch_size = 32
    input_channels = 2  # TRIX and WPR
    sequence_length = 1  # Adjustable based on your needs
    num_epochs = 10
    learning_rate = 0.0005

    # Create sequences
    X_tensor, y_tensor = create_sequences(x_train, y_train, sequence_length)
    num_sequences = X_tensor.shape[0]
    
    # Initialize model
    model = CosineConv1D(
        input_channels=input_channels,
        base_channels=128,
        num_layers=16,
        input_length=sequence_length,
        max_kernel_size=7,
        frequency=0.5,
        dropout_rate=0.03
    )
    
    # Loss and optimizer
    criterion = nn.BCELoss()
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    
    # Training loop
    model.train()
    for epoch in range(num_epochs):
        total_loss = 0
        for i in range(0, num_sequences, batch_size):
            batch_X = X_tensor[i:i+batch_size]  # Shape: (batch_size, 2, sequence_length)
            batch_y = y_tensor[i:i+batch_size]
            
            outputs = model(batch_X)
            loss = criterion(outputs, batch_y)
            
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            
            total_loss += loss.item()
        
        avg_loss = total_loss / (num_sequences // batch_size)
        print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {avg_loss:.4f}")
    
    # Export model to ONNX
    model.eval()
    dummy_input = torch.randn(1, input_channels, sequence_length)
    torch.onnx.export(
        model,
        dummy_input,
        inp_model_name,
        export_params=True,
        opset_version=11,
        do_constant_folding=True,
        input_names=['input'],
        output_names=['output'],
        dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}
    )
    print("\nModel exported to: ", inp_model_name)

    # Load and verify ONNX model
    try:
        onnx_model = onnx.load(inp_model_name)
        onnx.checker.check_model(onnx_model)
        print(f" ONNX model '{inp_model_name}' has been successfully exported and validated!")

        session = ort.InferenceSession(inp_model_name)

        for i in session.get_inputs():
            print(f" Input: {i.name}, Shape: {i.shape}, Type: {i.type}")

        for o in session.get_outputs():
            print(f" Output: {o.name}, Shape: {o.shape}, Type: {o.type}")

    except onnx.onnx_cpp2py_export.checker.ValidationError as e:
        print(f" ONNX model validation failed: {e}")
    except Exception as e:
        print(f" An error occurred: {e}")
        
    
    # Evaluate on a single sample
    with torch.no_grad():
        single_input = X_tensor[0:1]  # Shape: (1, 2, 50)
        scalar_output = model(single_input).squeeze()
        print(f"\nSingle sample input shape: {single_input.shape}")
        print(f"Single sample output shape: {scalar_output.shape}")
        print(f"Single sample output value: {scalar_output.item():.4f}")

Estos pasos son importantes, ya que los hiperparámetros controlan la eficiencia del entrenamiento, la capacidad del modelo y la velocidad de convergencia. Una longitud de secuencia reducida simplificaría la introducción de datos, mientras que la tasa de aprendizaje afectaría a la estabilidad de la optimización. A menudo es recomendable establecer un tamaño de lote que oscile entre 16 y 64, dependiendo de las limitaciones de memoria. El canal de entradas, como ya se ha destacado, debe ajustarse a las características de los datos. Además, para lograr una mejor convergencia, se puede aumentar el número de épocas o ajustar la tasa de aprendizaje, idealmente dentro del intervalo de 0,0001 a 0,001.

Lo siguiente que hacemos, en nuestra función de entrenamiento, es inicializar una instancia del modelo. Hemos decidido instanciar dicho modelo con 128 canales base, 16 capas y una dropout baja de 0,03. Estamos entrenando con una CPU convencional y no con una GPU. Esta configuración de la arquitectura de red procesa entradas con secuencias de longitud 1 y logra un equilibrio entre la complejidad y la regularización.

A continuación, definimos la función de pérdida y el optimizador. Utiliza la pérdida de entropía cruzada binaria para la clasificación binaria y el optimizador Adam con una tasa de aprendizaje especificada. Esto es fundamental porque BCELoss es adecuado para la salida sigmoide del modelo. El optimizador Adam funciona de manera eficiente con tasas de aprendizaje adaptativas. BCELoss es ideal para tareas binarias y, en nuestro caso, estamos generando un único valor comprendido entre 0 y 1, lo cual es análogo. No obstante, se pueden considerar otros optimizadores, como el SGD o incluso emplear programadores de tasa de aprendizaje si la convergencia es demasiado lenta.

A continuación viene el bucle de entrenamiento. Esto configura el modelo en modo de entrenamiento mediante iteraciones a lo largo de épocas y lotes. Calcula predicciones, calcula la pérdida, lleva a cabo la retropropagación y actualiza los pesos. A continuación, muestra la pérdida media por época. Esto es importante, ya que el entrenamiento es el principio fundamental que optimiza el modelo minimizando la pérdida. El uso del procesamiento por lotes aumenta considerablemente la eficiencia. Al ejecutar esto, es importante supervisar los valores de pérdida y evaluar la tasa de convergencia. En caso de que se produzcan estancamientos en la pérdida, se pueden realizar ajustes en el número de épocas o en el tamaño del lote. Es importante asegurarse de que se llame a «zero-grad» para restablecer los gradientes.

A continuación, debemos encargarnos de la validación y la exportación de nuestro modelo a ONNX. Una vez finalizado el entrenamiento, generamos el modelo en formato ONNX con datos de entrada ficticios, lo validamos y, a continuación, creamos una sesión de ONNX-runtime. Esto permite utilizar tamaños de lote dinámicos. La exportación a ONNX es importante, ya que permite implementar el modelo en diversas plataformas, siendo MQL5 la más relevante para nosotros. En esta fase también verificamos la integridad de la exportación, lo que evita una gran cantidad de errores posteriores en caso de que el modelo se utilice más adelante. Al exportar, es importante asegurarse de que la versión de opset sea compatible con la plataforma de destino. Por ahora, la versión 12 funciona bien con MQL5. Los errores de validación pueden solucionarse comprobando la compatibilidad del modelo.

A continuación, tenemos el código para evaluar o probar nuestro modelo ya entrenado. Esto realiza la evaluación en una única secuencia sin calcular el gradiente, mostrando las formas de entrada y salida, así como el resultado escalar. Esto permite verificar el funcionamiento del modelo basándose en los pesos de entrenamiento, así como el formato de salida para una sola muestra. Esto puede resultar útil para la depuración. También puede utilizarse para confirmar el comportamiento del modelo. Al utilizarlo, es importante asegurarse de que la forma de los datos de entrada coincida con la de los datos de entrenamiento. También se debe comprobar el rango de salida para asegurarse de que se encuentra entre 0 y 1.

En resumen, estas dos funciones auxiliares de nuestra clase de red —las funciones «create-sequences» y «train-and-evaluate»— se encargan de preparar los datos y entrenar el modelo CosineConv1D para la clasificación binaria. Esto se lleva a cabo mediante una arquitectura modulada por coseno. Los pasos más importantes son: crear una secuencia, ajustar los hiperparámetros, entrenar el modelo, exportar el modelo a ONNX y realizar la evaluación. Al llevarlas a cabo, es necesario adoptar medidas adicionales, como ajustar la longitud de la secuencia, la tasa de aprendizaje y el número de épocas, para lograr un rendimiento óptimo. La verificación del modelo ONNX antes de exportarlo también es fundamental.


Implementación en MQL5

En artículos anteriores, en los que analizamos la aplicación del aprendizaje automático para ampliar el uso de los patrones de señales de los indicadores, pasamos por alto la implementación en MQL5, ya que siempre parecíamos quedarnos sin «espacio». En este artículo, dado que la implementación de funciones en Python se estaba volviendo demasiado repetitiva, he pensado que podríamos abordar algunos aspectos que debemos tener en cuenta en MQL5 a la hora de importar y utilizar el modelo ONNX exportado. 

En la clase de señal personalizada que importa los modelos ONNX y se integra en un asesor experto a través del asistente de MQL5, nuestras condiciones de compra y venta se definen de la siguiente manera:

//+------------------------------------------------------------------+
//| "Voting" that price will grow.                                   |
//+------------------------------------------------------------------+
int CSignalML_TRX_WPR::LongCondition(void)
{  int result  = 0, results = 0;
   vectorf _x;
   _x.Init(2);
   _x.Fill(0.0);
//--- if the model 1 is used
   if(((m_patterns_usage & 0x02) != 0) && IsPattern_1(POSITION_TYPE_BUY))
   {  _x[0] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_BUY, _x);
      if(_y > 0.0)
      {  result += m_pattern_1;
         results++;
      }
   }
//--- if the model 4 is used
   if(((m_patterns_usage & 0x10) != 0) && IsPattern_4(POSITION_TYPE_BUY))
   {  _x[0] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_BUY, _x);
      if(_y > 0.0)
      {  result += m_pattern_4;
         results++;
      }
   }
//--- if the model 5 is used
   if(((m_patterns_usage & 0x20) != 0) && IsPattern_5(POSITION_TYPE_BUY))
   {  _x[0] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_BUY, _x);
      if(_y > 0.0)
      {  result += m_pattern_5;
         results++;
      }
   }
//--- return the result
//if(result > 0)printf(__FUNCSIG__+" result is: %i",result);
   if(results > 0 && result > 0)
   {  return(int(round(result / results)));
   }
   return(0);
}
//+------------------------------------------------------------------+
//| "Voting" that price will fall.                                   |
//+------------------------------------------------------------------+
int CSignalML_TRX_WPR::ShortCondition(void)
{  int result  = 0, results = 0;
   vectorf _x;
   _x.Init(2);
   _x.Fill(0.0);
//--- if the model 1 is used
   if(((m_patterns_usage & 0x02) != 0) && IsPattern_1(POSITION_TYPE_SELL))
   {  _x[1] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_SELL, _x);
      if(_y < 0.0)
      {  result += m_pattern_1;
         results++;
      }
   }
//--- if the model 4 is used
   if(((m_patterns_usage & 0x10) != 0) && IsPattern_4(POSITION_TYPE_SELL))
   {  _x[1] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_SELL, _x);
      if(_y < 0.0)
      {  result += m_pattern_4;
         results++;
      }
   }
//--- if the model 5 is used
   if(((m_patterns_usage & 0x20) != 0) && IsPattern_5(POSITION_TYPE_SELL))
   {  _x[1] = 1.0f;
      double _y = RunModel(0, POSITION_TYPE_SELL, _x);
      if(_y < 0.0)
      {  result += m_pattern_5;
         results++;
      }
   }
//--- return the result
//if(result > 0)printf(__FUNCSIG__+" result is: %i",result);
   if(results > 0 && result > 0)
   {  return(int(round(result / results)));
   }
   return(0);
}

Como se desprende de estas dos funciones, recurrimos con frecuencia a la función «RunModel». Su código es el siguiente:

//+------------------------------------------------------------------+
//| Forward Feed Network, to Get Forecast State.                     |
//+------------------------------------------------------------------+
double CSignalML_TRX_WPR::RunModel(int Index, ENUM_POSITION_TYPE T, vectorf &X)
{  vectorf _y(1);
   _y.Fill(0.0);
   ResetLastError();
   if(!OnnxRun(m_handles[Index], ONNX_NO_CONVERSION, X, _y))
   {  printf(__FUNCSIG__ + " failed to get y forecast, err: %i", GetLastError());
      return(double(_y[0]));
   }
   //printf(__FUNCSIG__ + " y: "+DoubleToString(_y[0],5));
   if(T == POSITION_TYPE_BUY && _y[0] > 0.5f)
   {  _y[0] = 2.0f * (_y[0] - 0.5f);
   }
   else if(T == POSITION_TYPE_SELL && _y[0] < 0.5f)
   {  _y[0] = 2.0f * (0.5f - _y[0]);
   }
   return(double(_y[0]));
}

Esta clase principal, para todas estas funciones, hereda de la clase base «CExpertSignal» para el procesamiento de señales de los asesores expertos de MQL5. Esto permite la integración con el ecosistema o los archivos de clase que utilizan los Expert Advisors creados mediante el asistente. Entrenamos nuestros tres modelos para cada uno de los patrones que fueron capaces de superar la prueba walk-forward: el 1, el 4 y el 5; y, al importarlos a MQL5, las pruebas nos ofrecen los siguientes informes:

 r1

c1

Para el patrón 1

r4

c4

Para el patrón 4

r5

c5

Para el patrón 5

Para los nuevos lectores, hay una guía introductoria aquí, con enlaces adicionales sobre cómo utilizar el código adjunto para crear un asesor experto mediante el asistente de MQL5 (Wizard MQL5). La clase de señal personalizada que hemos creado está diseñada para integrarse fácilmente con el asistente de MQL5, lo que permite reutilizarla en diferentes asesores expertos. La integración del aprendizaje automático ya es una realidad porque podemos usar los modelos ONNX para mejorar las capacidades predictivas de los asesores expertos. Para ello es necesario disponer de un conjunto sólido de datos de entrenamiento. La validación de los modelos mediante pruebas fuera de la muestra es importante para evitar el sobreajuste, y por eso, a efectos de nuestra demostración, dividimos nuestros datos en dos partes iguales: entrenamos con una de ellas durante un año y realizamos el «walk-forward» con la otra.

De las pruebas realizadas se desprende que todos los modelos 1, 4 y 5 superaron la prueba walk-forward, aunque parece que solo el 4 lo hizo de forma más «convincente». Estas pruebas presentan importantes limitaciones, además de que el plazo para realizarlas es muy breve. La principal de ellas sería que las pruebas se realizan cuando las posiciones abiertas tienen un objetivo de precio de «take profit» sin «stop-loss». El uso de órdenes limitadas para abrir posiciones también hace que los resultados parezcan algo más favorables de lo que probablemente serían en condiciones más realistas. Todas estas son consideraciones que el lector debe tener en cuenta a la hora de interpretar o valorar si conviene seguir desarrollando el código fuente adjunto.


Conclusión

Hemos analizado cómo las señales del oscilador de la media móvil exponencial triple pueden combinarse con el oscilador Williams Percent Range (WPR) y procesarse mediante un modelo de aprendizaje automático para realizar previsiones. Dado que los patrones analizados ya habían superado previamente la prueba walk-forward, nuestro modelo de aprendizaje automático actuó, en esencia, como un filtro sobre las operaciones que sabíamos que podían superar la validación walk-forward durante el año de prueba. El rendimiento mejoró, aunque marginalmente; sin embargo, en futuros artículos consideraremos probar también con patrones que no lograron superar la validación walk-forward, incorporándoles aprendizaje automático.

Nombre Descripción
wz-68.mq5 Asesor Experto ensamblado con el asistente MQL5 (Wizard MQL5), cuya cabecera detalla los archivos utilizados en su creación.
SignalWZ_68.mqh Archivo de clase de señal personalizado utilizado en el ensamblado del asistente.
68_1.mqh Modelo ONNX exportado para el patrón 1.
68_4.mqh Modelo ONNX exportado para el patrón 4.
68_5.mqh Modelo ONNX exportado para el patrón 5.

Traducción del inglés realizada por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/en/articles/18305

Archivos adjuntos |
wz-68.mq5 (6.89 KB)
SignalWZ_68.mqh (13.98 KB)
68_1.onnx (6538.3 KB)
68_4.onnx (6538.3 KB)
68_5.onnx (6538.3 KB)
De novato a experto: Desmitificando los niveles ocultos de retroceso de Fibonacci De novato a experto: Desmitificando los niveles ocultos de retroceso de Fibonacci
En este artículo, analizamos un enfoque basado en datos para descubrir y validar niveles de retroceso de Fibonacci no estándar que los mercados podrían respetar. Presentamos un flujo de trabajo completo diseñado específicamente para su implementación en MQL5, que comienza con la recopilación de datos y la detección de barras o swings, y se extiende hasta la agrupación en clústeres, las pruebas de hipótesis estadísticas, el backtesting y la integración en una herramienta de Fibonacci de MetaTrader 5. El objetivo es crear un proceso reproducible que transforme las observaciones anecdóticas en señales de trading estadísticamente defendibles.
Motor de decisión Multi-IA para MQL5 (Parte 3): Darle a las IA el contexto correcto — régimen de mercado y noticias Motor de decisión Multi-IA para MQL5 (Parte 3): Darle a las IA el contexto correcto — régimen de mercado y noticias
Tercera parte de la serie: le damos al motor multi-IA el contexto donde un modelo de lenguaje sí aporta. Leemos el régimen de mercado en el código (tendencia o rango con ADX, volatilidad con el ATR contra su promedio, dirección con la pendiente de una EMA) y definimos ventanas de noticias de alto impacto configurables, sin depender del calendario del broker. Ambos entran en un prompt más rico que le pide a cada IA razonar el contexto —no el próximo tick— y devolver una bandera de riesgo. Un gating de dos capas, por horario y por consenso de las IA, mantiene al motor fuera del mercado cuando el contexto pesa más que la señal.
Particularidades del trabajo con números del tipo double en MQL4 Particularidades del trabajo con números del tipo double en MQL4
En estos apuntes hemos reunido consejos para resolver los errores más frecuentes al trabajar con números del tipo double en los programas en MQL4.
Del básico al intermedio: Colas, listas y árboles (III) Del básico al intermedio: Colas, listas y árboles (III)
En este artículo daremos el siguiente paso para implementar y entender qué es y cómo funciona una lista enlazada. Aunque el contenido de este artículo pueda resultar bastante denso y confuso para quienes se están iniciando, intenté explicarlo de la forma más didáctica posible. Así podrás entender por qué y cuándo usar una lista enlazada.