Keltner季節フィルター戦略
過去の月次リターンで絞り込むケルトナーチャネルのブレイク戦略です。確定5分足で 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 にストップを記録します。
確定足で最初にストップを確認し、ロングは Close <= stop、ショートは Close >= stop なら決済し、ストップを消してその足の処理を終えます。その他はエントリーを確認した後、残ったロングは E 未満、ショートは E 超で決済します。これはシグナル終値を基準に確定足で判定する手動ストップです。足の途中の取引所ストップ、利確、トレーリングはありません。
SeasonalityHistoryFile が必須です。月次始値・終値を含む UTF-8 CSV で、ヘッダーは SecurityId,Month,Open,Close、Month は yyyy-MM、小数点はピリオドです。価格は正、同一銘柄の月は重複不可です。他の証券 ID は無視します。未指定、不正な行、対象銘柄のデータがない場合は起動できません。
各シグナル足の 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()