Keltner Seasonal Filter 策略
交易带有历史月度收益过滤的肯特纳通道突破。已完成5分钟 K 线上使用 E = EMA(20)、A = ATR(14)、M = AtrMultiplier = 2、U = E + M × A、L = E − M × A,以及 SeasonalThreshold = 0.5 个百分点。必须等待指标形成并允许交易。
S > SeasonalThreshold、Close > U、Position <= 0 时买入;S < -SeasonalThreshold、Close < L、Position >= 0 时卖出。订单数量为 Volume + abs(Position),可完整反转相反持仓。多头止损设为信号 Close − M × A,空头为 Close + M × A。
每根已完成 K 线先检查止损:多头 Close <= stop 或空头 Close >= stop 则平仓,清除止损并结束该根处理。否则检查入场,之后多头低于 E 或空头高于 E 时平仓。这是基于信号收盘价、在 K 线收盘检查的手动止损;没有盘中交易所止损、止盈或追踪止损。
必须设置 SeasonalityHistoryFile:包含月开盘价和收盘价的 UTF-8 CSV,表头为 SecurityId,Month,Open,Close。Month 格式为 yyyy-MM,数字使用小数点,价格必须为正,同一交易标的的月份不能重复。其他证券 ID 的行被忽略;输入缺失、格式错误或无匹配记录会阻止启动。
每根信号 K 线的 S 为过去 SeasonalityLookbackYears = 10 年中相同日历月份的 100 × (Close / Open − 1) 平均值。至少需要 MinimumSeasonalSamples = 2 个观测值。即使文件包含当前月及未来月,也不使用它们。历史不足时不开新仓。应提供该标的的真实月度价格,不提供固定的月份评分表。
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()