Seasonality Adjusted Momentum
Combines momentum with historical monthly returns. On finished 5-minute candles, calculate M = Momentum(14) and A = SMA(14) of M. From flat, buy when S >= SeasonalityThreshold and M > A; sell when S <= -SeasonalityThreshold and M < A. Default SeasonalityThreshold = 0.5 is in percentage points.
Close a long if its seasonal condition is no longer satisfied or M < A; close a short if its seasonal condition fails or M > A. Every signal order starts a CooldownBars = 120 pause in finished candles for subsequent signal decisions. Filled positions have separate local native StopLossPercent = 2 protection from the actual execution price, monitored on bid/ask and executed at market; it does not wait for signal cooldown.
Requires SeasonalityHistoryFile: a UTF-8 CSV of monthly open/close prices with header SecurityId,Month,Open,Close. Month is yyyy-MM, numbers use a decimal point, prices must be positive, and each month must be unique for the execution instrument. Rows for other security IDs are ignored; missing input, malformed rows or no matching observations prevent startup.
At each signal candle, S is the mean of 100 × (Close / Open − 1) for the same calendar month in prior years, within SeasonalityLookbackYears = 10. At least MinimumSeasonalSamples = 2 observations are required. The current month and future months are excluded even if present in the file. Without sufficient history, no new position is opened. Supply actual monthly prices for the instrument; no fixed monthly score table is supplied.
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()