Estrategia de Momentum Ajustado por Estacionalidad
Combina momentum con rendimientos mensuales históricos. Sobre velas completas de 5 minutos calcula M = Momentum(14) y A = SMA(14) de M. Sin posición, compra si S >= SeasonalityThreshold y M > A; vende si S <= -SeasonalityThreshold y M < A. SeasonalityThreshold = 0.5 por defecto, en puntos porcentuales.
Cierra largos si deja de cumplirse su condición estacional o M < A, y cortos si falla su condición o M > A. Cada orden de señal impone CooldownBars = 120 velas completas de pausa para nuevas decisiones. Las posiciones ejecutadas tienen protección nativa local separada StopLossPercent = 2 desde el precio real, con bid/ask y salida a mercado sin esperar la pausa.
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.Collections.Generic;
using System.Linq;
using Ecng.Common;
using StockSharp.Algo;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Momentum strategy that allows longs or shorts only when the current month historically supports that seasonal bias.
/// </summary>
public class SeasonalityAdjustedMomentumStrategy : Strategy
{
private readonly StrategyParam<int> _momentumPeriod;
private readonly StrategyParam<decimal> _seasonalityThreshold;
private readonly StrategyParam<decimal> _stopLossPercent;
private readonly StrategyParam<int> _cooldownBars;
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 Momentum _momentum;
private SimpleMovingAverage _momentumAverage;
private int _cooldown;
/// <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>
/// Period for the momentum indicator.
/// </summary>
public int MomentumPeriod
{
get => _momentumPeriod.Value;
set => _momentumPeriod.Value = value;
}
/// <summary>
/// Minimum absolute seasonality strength required to allow directional entries.
/// </summary>
public decimal SeasonalityThreshold
{
get => _seasonalityThreshold.Value;
set => _seasonalityThreshold.Value = value;
}
/// <summary>
/// Stop loss percentage.
/// </summary>
public decimal StopLossPercent
{
get => _stopLossPercent.Value;
set => _stopLossPercent.Value = value;
}
/// <summary>
/// Bars to wait after each order.
/// </summary>
public int CooldownBars
{
get => _cooldownBars.Value;
set => _cooldownBars.Value = value;
}
/// <summary>
/// Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Initializes strategy parameters.
/// </summary>
public SeasonalityAdjustedMomentumStrategy()
{
_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");
_momentumPeriod = Param(nameof(MomentumPeriod), 14)
.SetRange(3, 100)
.SetDisplay("Momentum Period", "Period for the momentum indicator", "Indicators");
_seasonalityThreshold = Param(nameof(SeasonalityThreshold), 0.5m)
.SetRange(0m, 1m)
.SetDisplay("Seasonality Threshold", "Minimum absolute seasonality strength required for entries", "Signals");
_stopLossPercent = Param(nameof(StopLossPercent), 2m)
.SetRange(0.5m, 10m)
.SetDisplay("Stop Loss %", "Stop loss percentage", "Risk");
_cooldownBars = Param(nameof(CooldownBars), 120)
.SetRange(1, 500)
.SetDisplay("Cooldown Bars", "Bars to wait after each order", "Risk");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Type of candles for the strategy", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
if (Security != null)
yield return (Security, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_seasonalHistory.Clear();
_seasonalMonth = null;
_seasonalValue = null;
_momentum = null;
_momentumAverage = null;
_cooldown = 0;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
LoadSeasonalityHistory();
base.OnStarted2(time);
if (Security == null)
throw new InvalidOperationException("Security is not specified.");
_momentum = new Momentum { Length = MomentumPeriod };
_momentumAverage = new SimpleMovingAverage { Length = MomentumPeriod };
_cooldown = 0;
var subscription = SubscribeCandles(CandleType);
subscription
.Bind(_momentum, ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, _momentum);
DrawIndicator(area, _momentumAverage);
DrawOwnTrades(area);
}
StartProtection(new Unit(0, UnitTypes.Absolute), new Unit(StopLossPercent, UnitTypes.Percent), useMarketOrders: true, isLocalStop: true);
foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
{
var quotes = new Subscription(DataType.Level1, Security);
quotes.MarketData.BuildField = field;
SubscribeLevel1(quotes).Start();
}
}
private void ProcessCandle(ICandleMessage candle, decimal momentumValue)
{
if (candle.State != CandleStates.Finished)
return;
var momentumAvgValue = _momentumAverage.Process(momentumValue, candle.OpenTime, true).ToDecimal();
if (!_momentum.IsFormed || !_momentumAverage.IsFormed)
return;
if (ProcessState != ProcessStates.Started)
return;
if (_cooldown > 0)
{
_cooldown--;
return;
}
var seasonalStrength = GetSeasonalStrength(candle.OpenTime);
var allowLong = seasonalStrength >= SeasonalityThreshold;
var allowShort = seasonalStrength <= -SeasonalityThreshold;
var bullishMomentum = momentumValue > momentumAvgValue;
var bearishMomentum = momentumValue < momentumAvgValue;
if (Position > 0)
{
if (!allowLong || bearishMomentum)
{
SellMarket(Math.Abs(Position));
_cooldown = CooldownBars;
}
return;
}
if (Position < 0)
{
if (!allowShort || bullishMomentum)
{
BuyMarket(Math.Abs(Position));
_cooldown = CooldownBars;
}
return;
}
if (allowLong && bullishMomentum)
{
BuyMarket();
_cooldown = CooldownBars;
}
else if (allowShort && bearishMomentum)
{
SellMarket();
_cooldown = CooldownBars;
}
}
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;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import DateTime, InvalidOperationException
from System.Globalization import CultureInfo
from System import TimeSpan, Math, Decimal
from StockSharp.Messages import DataType, CandleStates, Unit, UnitTypes
from StockSharp.Algo.Indicators import Momentum, SimpleMovingAverage
from StockSharp.BusinessEntities import Subscription
from StockSharp.Messages import Level1Fields
from StockSharp.Algo.Strategies import Strategy
from indicator_extensions import *
class seasonality_adjusted_momentum_strategy(Strategy):
"""
Momentum strategy that allows longs or shorts only when the current month
historically supports that seasonal bias.
"""
def __init__(self):
super(seasonality_adjusted_momentum_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._momentum_period = self.Param("MomentumPeriod", 14) \
.SetDisplay("Momentum Period", "Period for the momentum indicator", "Indicators")
self._seasonality_threshold = self.Param("SeasonalityThreshold", 0.5) \
.SetDisplay("Seasonality Threshold", "Minimum absolute seasonality strength required for entries", "Signals")
self._stop_loss_percent = self.Param("StopLossPercent", 2.0) \
.SetDisplay("Stop Loss %", "Stop loss percentage", "Risk")
self._cooldown_bars = self.Param("CooldownBars", 120) \
.SetDisplay("Cooldown Bars", "Bars to wait after each order", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))) \
.SetDisplay("Candle Type", "Type of candles for the strategy", "General")
self._seasonal_history = []
self._momentum = None
self._momentum_average = None
self._cooldown = 0
@property
def candle_type(self):
return self._candle_type.Value
def OnReseted(self):
super(seasonality_adjusted_momentum_strategy, self).OnReseted()
self._seasonal_history = []
self._seasonal_month = None
self._seasonal_value = None
self._momentum = None
self._momentum_average = None
self._cooldown = 0
self._seasonal_history.clear()
def OnStarted2(self, time):
self._load_seasonality_history()
super(seasonality_adjusted_momentum_strategy, self).OnStarted2(time)
mp = int(self._momentum_period.Value)
self._momentum = Momentum()
self._momentum.Length = mp
self._momentum_average = SimpleMovingAverage()
self._momentum_average.Length = mp
self._cooldown = 0
subscription = self.SubscribeCandles(self.candle_type)
subscription.Bind(self._momentum, self._process_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, self._momentum)
self.DrawIndicator(area, self._momentum_average)
self.DrawOwnTrades(area)
self.StartProtection(Unit(0, UnitTypes.Absolute), Unit(self._stop_loss_percent.Value, UnitTypes.Percent), useMarketOrders=True, isLocalStop=True)
for field in (Level1Fields.BestBidPrice, Level1Fields.BestAskPrice):
quotes = Subscription(DataType.Level1, self.Security)
quotes.MarketData.BuildField = field
self.SubscribeLevel1(quotes).Start()
def _process_candle(self, candle, momentum_value):
if candle.State != CandleStates.Finished:
return
mv = float(momentum_value)
momentum_avg_val = float(process_float(self._momentum_average, Decimal(mv), candle.OpenTime, True))
if not self._momentum.IsFormed or not self._momentum_average.IsFormed:
return
if not self.IsFormedAndOnlineAndAllowTrading():
return
if self._cooldown > 0:
self._cooldown -= 1
return
seasonal_strength = self._get_seasonal_strength(candle.OpenTime)
st = float(self._seasonality_threshold.Value)
allow_long = seasonal_strength is not None and seasonal_strength >= st
allow_short = seasonal_strength is not None and seasonal_strength <= -st
bullish_momentum = mv > momentum_avg_val
bearish_momentum = mv < momentum_avg_val
cd = int(self._cooldown_bars.Value)
if self.Position > 0:
if not allow_long or bearish_momentum:
self.SellMarket(Math.Abs(self.Position))
self._cooldown = cd
return
if self.Position < 0:
if not allow_short or bullish_momentum:
self.BuyMarket(Math.Abs(self.Position))
self._cooldown = cd
return
if allow_long and bullish_momentum:
self.BuyMarket()
self._cooldown = cd
elif allow_short and bearish_momentum:
self.SellMarket()
self._cooldown = cd
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 CreateClone(self):
return seasonality_adjusted_momentum_strategy()