Estrategia Keltner con Filtro Estacional
Opera rupturas del canal Keltner filtradas por rendimientos mensuales históricos. En velas completas de 5 minutos: E = EMA(20), A = ATR(14), M = AtrMultiplier = 2, U = E + M × A, L = E − M × A; SeasonalThreshold = 0.5 puntos porcentuales. Espera indicadores formados y permiso para operar.
Compra con S > SeasonalThreshold, Close > U, Position <= 0; vende con S < -SeasonalThreshold, Close < L, Position >= 0. El volumen Volume + abs(Position) revierte toda posición contraria. Guarda un stop en Close − M × A para largos o Close + M × A para cortos, usando el cierre de señal.
Primero comprueba el stop en cada vela completa: cierra largos con Close <= stop o cortos con Close >= stop, borra el nivel y termina esa vela. En otro caso procesa entradas y después cierra largos bajo E o cortos sobre E. Es un stop manual comprobado al cierre y basado en el cierre de señal; no hay stop bursátil intravela, objetivo de beneficios ni trailing.
Requiere SeasonalityHistoryFile: CSV UTF-8 de aperturas y cierres mensuales con cabecera SecurityId,Month,Open,Close. Month usa yyyy-MM, los decimales un punto, los precios deben ser positivos y cada mes debe ser único para el instrumento. Ignora otros identificadores. La falta de datos, filas mal formadas o ausencia de observaciones coincidentes impiden iniciar.
En cada vela de señal, S es la media de 100 × (Close / Open − 1) para ese mismo mes en años anteriores, dentro de SeasonalityLookbackYears = 10. Exige MinimumSeasonalSamples = 2 observaciones. Excluye el mes actual y los futuros aunque aparezcan en el archivo. Sin historial suficiente no abre posiciones. Deben aportarse precios mensuales reales del instrumento; no hay una tabla fija de puntuaciones.
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Strategy that trades based on Keltner Channel breakouts with seasonal bias filter.
/// Enters position when price breaks Keltner Channel with seasonal bias confirmation.
/// </summary>
public class KeltnerSeasonalStrategy : Strategy
{
private readonly StrategyParam<int> _emaPeriod;
private readonly StrategyParam<int> _atrPeriod;
private readonly StrategyParam<decimal> _atrMultiplier;
private readonly StrategyParam<decimal> _seasonalThreshold;
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<string> _seasonalityHistoryFile;
private readonly StrategyParam<int> _minimumSeasonalSamples;
private readonly StrategyParam<int> _seasonalityLookbackYears;
private readonly List<(DateTime month, decimal value)> _seasonalHistory = new();
private DateTime? _seasonalMonth;
private decimal? _seasonalValue;
private decimal _currentSeasonalStrength;
private bool _isSeasonalReady;
private decimal _stopPrice;
/// <summary>CSV monthly opening/closing prices: SecurityId,Month,Open,Close.</summary>
public string SeasonalityHistoryFile { get => _seasonalityHistoryFile.Value; set => _seasonalityHistoryFile.Value = value; }
/// <summary>Required completed observations of the same calendar month.</summary>
public int MinimumSeasonalSamples { get => _minimumSeasonalSamples.Value; set => _minimumSeasonalSamples.Value = value; }
/// <summary>Maximum number of prior years used for each seasonal estimate.</summary>
public int SeasonalityLookbackYears { get => _seasonalityLookbackYears.Value; set => _seasonalityLookbackYears.Value = value; }
/// <summary>
/// Strategy parameter: EMA period for Keltner Channel.
/// </summary>
public int EmaPeriod
{
get => _emaPeriod.Value;
set => _emaPeriod.Value = value;
}
/// <summary>
/// Strategy parameter: ATR period for Keltner Channel.
/// </summary>
public int AtrPeriod
{
get => _atrPeriod.Value;
set => _atrPeriod.Value = value;
}
/// <summary>
/// Strategy parameter: ATR multiplier for Keltner Channel.
/// </summary>
public decimal AtrMultiplier
{
get => _atrMultiplier.Value;
set => _atrMultiplier.Value = value;
}
/// <summary>
/// Strategy parameter: Seasonal strength threshold.
/// </summary>
public decimal SeasonalThreshold
{
get => _seasonalThreshold.Value;
set => _seasonalThreshold.Value = value;
}
/// <summary>
/// Strategy parameter: Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public KeltnerSeasonalStrategy()
{
_seasonalityHistoryFile = Param<string>(nameof(SeasonalityHistoryFile)).SetRequired()
.SetDisplay("Seasonality History File", "CSV monthly prices for the execution instrument", "Seasonality");
_minimumSeasonalSamples = Param(nameof(MinimumSeasonalSamples), 2).SetGreaterThanZero()
.SetDisplay("Minimum Seasonal Samples", "Completed observations required for the same month", "Seasonality");
_seasonalityLookbackYears = Param(nameof(SeasonalityLookbackYears), 10).SetRange(1, 100)
.SetDisplay("Seasonality Lookback Years", "Historical years used by the seasonal estimate", "Seasonality");
_emaPeriod = Param(nameof(EmaPeriod), 20)
.SetGreaterThanZero()
.SetDisplay("EMA Period", "Period for EMA in Keltner Channel", "Indicator Settings");
_atrPeriod = Param(nameof(AtrPeriod), 14)
.SetGreaterThanZero()
.SetDisplay("ATR Period", "Period for ATR in Keltner Channel", "Indicator Settings");
_atrMultiplier = Param(nameof(AtrMultiplier), 2m)
.SetGreaterThanZero()
.SetDisplay("ATR Multiplier", "Multiplier for ATR to set channel width", "Indicator Settings");
_seasonalThreshold = Param(nameof(SeasonalThreshold), 0.5m)
.SetDisplay("Seasonal Threshold", "Minimum seasonal strength to consider for trading", "Seasonal Settings");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to use", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_seasonalHistory.Clear();
_seasonalMonth = null;
_seasonalValue = null;
_isSeasonalReady = false;
_currentSeasonalStrength = 0;
_stopPrice = 0;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
LoadSeasonalityHistory();
base.OnStarted2(time);
// Create indicators for Keltner Channel
var ema = new EMA
{
Length = EmaPeriod
};
var atr = new AverageTrueRange
{
Length = AtrPeriod
};
// Create subscription for candles
var subscription = SubscribeCandles(CandleType);
// Bind indicators to subscription and start
subscription
.Bind(ema, atr, ProcessKeltner)
.Start();
// Add chart visualization
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, ema);
DrawOwnTrades(area);
}
}
/// <summary>
/// Evaluates seasonal Keltner entries: 1 for buy, -1 for sell and 0 for no entry.
/// </summary>
public static int GetEntrySignal(
decimal seasonalStrength,
decimal close,
decimal ema,
decimal atr,
decimal multiplier,
decimal threshold,
decimal position)
{
if (seasonalStrength > threshold && close > ema + atr * multiplier && position <= 0m)
return 1;
if (seasonalStrength < -threshold && close < ema - atr * multiplier && position >= 0m)
return -1;
return 0;
}
private void ProcessKeltner(ICandleMessage candle, decimal emaValue, decimal atrValue)
{
// Skip unfinished candles
if (candle.State != CandleStates.Finished)
return;
// Check if strategy is ready to trade
if (!IsFormedAndOnlineAndAllowTrading())
return;
// Seasonality belongs to the candle being evaluated, never to host/start time.
UpdateSeasonalStrength(candle.OpenTime);
// Calculate Keltner Channel bands
decimal upperBand = emaValue + atrValue * AtrMultiplier;
decimal lowerBand = emaValue - atrValue * AtrMultiplier;
// Stop placed AtrMultiplier x ATR from the entry close, checked on the finished candle
if (_stopPrice > 0m &&
((Position > 0 && candle.ClosePrice <= _stopPrice) ||
(Position < 0 && candle.ClosePrice >= _stopPrice)))
{
LogInfo($"Stop exit: close {candle.ClosePrice} reached ATR stop ({_stopPrice})");
ClosePosition();
_stopPrice = 0m;
return;
}
var signal = _isSeasonalReady ? GetEntrySignal(
_currentSeasonalStrength, candle.ClosePrice, emaValue, atrValue,
AtrMultiplier, SeasonalThreshold, Position) : 0;
if (signal > 0)
{
LogInfo($"Buy signal: breakout above Keltner upper band ({upperBand}) with seasonal bias ({_currentSeasonalStrength})");
BuyMarket(Volume + Math.Abs(Position));
_stopPrice = candle.ClosePrice - atrValue * AtrMultiplier;
return;
}
if (signal < 0)
{
LogInfo($"Sell signal: breakout below Keltner lower band ({lowerBand}) with seasonal bias ({_currentSeasonalStrength})");
SellMarket(Volume + Math.Abs(Position));
_stopPrice = candle.ClosePrice + atrValue * AtrMultiplier;
return;
}
// Exit rules based on middle line reversion
if ((Position > 0 && candle.ClosePrice < emaValue) ||
(Position < 0 && candle.ClosePrice > emaValue))
{
LogInfo($"Exit signal: Price reverted to EMA ({emaValue})");
ClosePosition();
_stopPrice = 0m;
}
}
private void LoadSeasonalityHistory()
{
if (string.IsNullOrWhiteSpace(SeasonalityHistoryFile))
throw new InvalidOperationException("SeasonalityHistoryFile is required.");
_seasonalHistory.Clear();
_seasonalMonth = null;
_seasonalValue = null;
var lines = File.ReadLines(SeasonalityHistoryFile).GetEnumerator();
using (lines)
{
if (!lines.MoveNext() || lines.Current.Trim() != "SecurityId,Month,Open,Close")
throw new InvalidOperationException("Seasonality history must begin with SecurityId,Month,Open,Close.");
var months = new HashSet<DateTime>();
while (lines.MoveNext())
{
if (string.IsNullOrWhiteSpace(lines.Current))
continue;
var columns = lines.Current.Split(',');
if (columns.Length != 4)
throw new InvalidOperationException("Every seasonal observation must contain four CSV fields.");
if (!string.Equals(columns[0].Trim(), Security.Id, StringComparison.OrdinalIgnoreCase))
continue;
var month = DateTime.ParseExact(columns[1].Trim(), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var open = decimal.Parse(columns[2], CultureInfo.InvariantCulture);
var close = decimal.Parse(columns[3], CultureInfo.InvariantCulture);
if (open <= 0m || close <= 0m || !months.Add(month))
throw new InvalidOperationException("Seasonality prices must be positive and months must be unique.");
_seasonalHistory.Add((month, 100m * (close / open - 1m)));
}
}
if (_seasonalHistory.Count == 0)
throw new InvalidOperationException("Seasonality history contains no observations for the execution instrument.");
}
private decimal? GetSeasonalStrength(DateTime time)
{
var month = new DateTime(time.Year, time.Month, 1, 0, 0, 0, DateTimeKind.Utc);
if (_seasonalMonth != month)
{
var earliest = month.AddYears(-SeasonalityLookbackYears);
var history = _seasonalHistory.Where(h => h.month.Month == month.Month
&& h.month < month && h.month >= earliest).Select(h => h.value).ToArray();
_seasonalValue = history.Length >= MinimumSeasonalSamples ? history.Average() : null;
_seasonalMonth = month;
}
return _seasonalValue;
}
private void UpdateSeasonalStrength(DateTime time)
{
var value = GetSeasonalStrength(time);
_isSeasonalReady = value.HasValue;
_currentSeasonalStrength = value ?? 0m;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import DateTime, Decimal, InvalidOperationException
from System.Globalization import CultureInfo
from System import TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Indicators import ExponentialMovingAverage, AverageTrueRange
from StockSharp.Algo.Strategies import Strategy
class keltner_seasonal_strategy(Strategy):
"""
Strategy that trades based on Keltner Channel breakouts with seasonal bias filter.
"""
def __init__(self):
super(keltner_seasonal_strategy, self).__init__()
self._seasonality_history_file = self.Param[str]("SeasonalityHistoryFile", None).SetRequired()
self._minimum_seasonal_samples = self.Param("MinimumSeasonalSamples", 2).SetGreaterThanZero()
self._seasonality_lookback_years = self.Param("SeasonalityLookbackYears", 10).SetRange(1, 100)
self._seasonal_history = []
self._seasonal_month = None
self._seasonal_value = None
self._ema_period = self.Param("EmaPeriod", 20) \
.SetGreaterThanZero() \
.SetDisplay("EMA Period", "Period for EMA in Keltner Channel", "Indicator Settings")
self._atr_period = self.Param("AtrPeriod", 14) \
.SetGreaterThanZero() \
.SetDisplay("ATR Period", "Period for ATR in Keltner Channel", "Indicator Settings")
self._atr_multiplier = self.Param("AtrMultiplier", 2.0) \
.SetGreaterThanZero() \
.SetDisplay("ATR Multiplier", "Multiplier for ATR to set channel width", "Indicator Settings")
self._seasonal_threshold = self.Param("SeasonalThreshold", 0.5) \
.SetDisplay("Seasonal Threshold", "Minimum seasonal strength to consider for trading", "Seasonal Settings")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))) \
.SetDisplay("Candle Type", "Type of candles to use", "General")
self._seasonal_history = []
self._current_seasonal_strength = 0.0
self._stop_price = 0.0
@property
def candle_type(self):
return self._candle_type.Value
def GetWorkingSecurities(self):
return [(self.Security, self.candle_type)]
def OnReseted(self):
super(keltner_seasonal_strategy, self).OnReseted()
self._seasonal_history = []
self._seasonal_month = None
self._seasonal_value = None
self._current_seasonal_strength = 0.0
self._stop_price = 0.0
def OnStarted2(self, time):
self._load_seasonality_history()
super(keltner_seasonal_strategy, self).OnStarted2(time)
ema = ExponentialMovingAverage()
ema.Length = int(self._ema_period.Value)
atr = AverageTrueRange()
atr.Length = int(self._atr_period.Value)
subscription = self.SubscribeCandles(self.candle_type)
subscription.Bind(ema, atr, self._process_keltner).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, ema)
self.DrawOwnTrades(area)
@staticmethod
def _get_entry_signal(seasonal_strength, close_price, ema, atr, multiplier, threshold, position):
"""Original directional Keltner breakout; +1 buy, -1 sell, 0 no entry."""
if seasonal_strength > threshold and close_price > ema + atr * multiplier and position <= 0:
return 1
if seasonal_strength < -threshold and close_price < ema - atr * multiplier and position >= 0:
return -1
return 0
def _process_keltner(self, candle, ema_value, atr_value):
if candle.State != CandleStates.Finished:
return
if not self.IsFormedAndOnlineAndAllowTrading():
return
# Seasonality belongs to the candle being evaluated, never to host/start time.
self._update_seasonal_strength(candle.OpenTime)
multiplier = float(self._atr_multiplier.Value)
threshold = float(self._seasonal_threshold.Value)
ema_val = float(ema_value)
atr_val = float(atr_value)
upper_band = ema_val + atr_val * multiplier
lower_band = ema_val - atr_val * multiplier
close_price = float(candle.ClosePrice)
# Stop placed AtrMultiplier x ATR from the entry close, checked on the finished candle
if self._stop_price > 0 and \
((self.Position > 0 and close_price <= self._stop_price) or
(self.Position < 0 and close_price >= self._stop_price)):
self._close_position()
return
signal = self._get_entry_signal(
self._current_seasonal_strength, close_price, ema_val, atr_val,
multiplier, threshold, self.Position
)
if not self._is_seasonal_ready:
signal = 0
if signal > 0:
self.BuyMarket(self.Volume + Math.Abs(self.Position))
self._stop_price = close_price - atr_val * multiplier
return
if signal < 0:
self.SellMarket(self.Volume + Math.Abs(self.Position))
self._stop_price = close_price + atr_val * multiplier
return
if (self.Position > 0 and close_price < ema_val) or \
(self.Position < 0 and close_price > ema_val):
self._close_position()
def _close_position(self):
if self.Position > 0:
self.SellMarket(self.Position)
elif self.Position < 0:
self.BuyMarket(Math.Abs(self.Position))
self._stop_price = 0.0
def _load_seasonality_history(self):
if self.Security is None:
raise InvalidOperationException("Security is required for seasonal history")
path = self._seasonality_history_file.Value
if not path or not str(path).strip():
raise InvalidOperationException("SeasonalityHistoryFile is required")
self._seasonal_history = []
self._seasonal_month = None
self._seasonal_value = None
months = set()
with open(str(path), "r", encoding="utf-8-sig") as stream:
if stream.readline().strip() != "SecurityId,Month,Open,Close":
raise InvalidOperationException("Seasonality history must begin with SecurityId,Month,Open,Close")
for line in stream:
if not line.strip():
continue
fields = line.strip().split(",")
if len(fields) != 4:
raise InvalidOperationException("Every seasonal observation must contain four CSV fields")
if fields[0].strip().lower() != self.Security.Id.lower():
continue
month = DateTime.ParseExact(fields[1].strip(), "yyyy-MM", CultureInfo.InvariantCulture)
opening = Decimal.Parse(fields[2], CultureInfo.InvariantCulture)
closing = Decimal.Parse(fields[3], CultureInfo.InvariantCulture)
if opening <= 0 or closing <= 0 or month in months:
raise InvalidOperationException("Seasonality prices must be positive and months must be unique")
months.add(month)
self._seasonal_history.append((month, Decimal.Multiply(Decimal(100), Decimal.Subtract(Decimal.Divide(closing, opening), Decimal.One))))
if not self._seasonal_history:
raise InvalidOperationException("Seasonality history contains no observations for the execution instrument")
def _get_seasonal_strength(self, time):
month = DateTime(time.Year, time.Month, 1)
if self._seasonal_month != month:
earliest = month.AddYears(-int(self._seasonality_lookback_years.Value))
values = [value for observed, value in self._seasonal_history
if observed.Month == month.Month and earliest <= observed < month]
minimum = int(self._minimum_seasonal_samples.Value)
self._seasonal_value = None
if len(values) >= minimum:
total = Decimal.Zero
for value in values:
total = Decimal.Add(total, value)
self._seasonal_value = float(Decimal.Divide(total, Decimal(len(values))))
self._seasonal_month = month
return self._seasonal_value
def _update_seasonal_strength(self, time):
value = self._get_seasonal_strength(time)
self._is_seasonal_ready = value is not None
self._current_seasonal_strength = value if value is not None else 0.0
def CreateClone(self):
return keltner_seasonal_strategy()