レンジフォロワー戦略
概要
レンジ フォロワー戦略は、StockSharp の高レベル API を使用して、MetaTrader 5 エキスパート アドバイザー「レンジ フォロワー」を再現します。毎日の平均トゥルーレンジ(ATR)ベンチマークと比較してその日の価格範囲を監視し、価格がセッションの高値または安値から十分に離れた場合に単一のブレイクアウト取引を開始します。この変換では、ATR をトリガー部分とテイクプロフィットディスタンスとなる残りの部分に分割するという元のアプローチが維持されます。
取引ロジック
- 毎日のボラティリティのベースライン
- 毎日のローソク足で計算された 20 期間の ATR は、現在の取引日のベースライン範囲を提供します。
- ATR の値は、
TriggerPercentによって、エントリーする前に超えなければならないトリガー距離と、利益目標として使用される残りの距離の 2 つのセグメントに分割されます。
- 範囲追跡
- この戦略は、アクティブな毎日のローソク足から現在のセッションの高値と安値を継続的に記録します。
- レベル 1 更新は、現在の相場からセッションの極値までの距離を測定するために使用される最新の最良買値と最良売値を提供します。
- エントリーは 1 日 1 回のみ
- 最良の入札額がセッション安値を上回るトリガー距離を超えており、まだ取引が開始されていない場合、この戦略は成行で購入します。
- ベストアスクがセッション高値を下回るトリガー距離を超えていて、まだ取引が開始されていない場合、戦略は市場で売却されます。
- 1 日に許可される取引は 1 回だけです。新しいセッションが開始されるとフラグはリセットされます。
- ストップロスとテイクプロフィット
- ロングポジションの場合、ストップロスはエントリー価格の 1 トリガー距離下に配置され、テイクプロフィットはエントリー価格の 1 残差上に配置されます。
- ショートポジションの場合、ストップロスはエントリー価格より 1 トリガー距離上、テイクプロフィットはエントリー価格より 1 残留距離下です。
- 価格監視はレベル 1 ティックとローソク足の両方で実行され、レベルを突破するとすぐにポジションをクローズします。
- 毎日のセッションのリセット
- 新しい取引日の最初のローソク足で、この戦略はオープンポジションをクローズし、内部状態をクリアして、ATR ベースラインをリロードします。
- セッションの初期化時に現在の日次レンジがすでにトリガー距離を超えている場合、元の EA の安全チェックを模倣するために、その日の残りの取引はスキップされます。
パラメーター
| 名前 | デフォルト | 説明 |
|---|---|---|
CandleType |
15分キャンドル | セッション境界を検出するために使用される作業時間枠。 |
TriggerPercent |
60 | ブレイクアウトトリガー距離として使用される毎日の ATR の割合。 10 ~ 90 の間にある必要があります。 |
Volume |
0.1 | ロングエントリーとショートエントリーの両方の成行注文量。 |
リスク管理
- ストップとターゲットは同じ ATR ベースラインから導出されるため、報酬対リスクの比率は常に
(100 - TriggerPercent) : TriggerPercentと等しくなります。 - この戦略は一度に 1 つのポジションを登録し、ストップまたはターゲットに触れるとすぐにポジションを清算して、複数の重複取引を防ぎます。
StartProtection()は、StockSharp の保護インフラストラクチャを有効にし、必要に応じて外部コンポーネントがトレーリング ストップやポートフォリオ ガードを接続できるようにします。
実装メモ
- 毎日の ATR 値は、専用の日次ローソク足サブスクリプションと、高レベルの API を通じてバインドされた
AverageTrueRangeインジケーターによって生成されます。 - レベル 1 データは、EA のティック主導の決定を反映するために必要です。最高買値と最高売値により、エントリーとエグジットのチェックの両方が行われます。
- 毎日のセッション境界は作業時間枠のローソク足から派生し、StockSharp で使用される取引カレンダーが一貫して戦略をリセットするようにします。
- この変換では、手動のインジケーター バッファーや履歴ループが回避され、代わりに
Bindコールバックによって更新されるステートフル フィールドに依存します。
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Daily ATR range follower using live best bid/ask and one entry per session.
/// </summary>
public class RangeFollowerStrategy : Strategy
{
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<decimal> _triggerPercent;
private decimal? _dailyAtr;
private decimal? _sessionAtr;
private decimal? _bestBid;
private decimal? _bestAsk;
private DateTime _sessionDate;
private decimal _sessionHigh;
private decimal _sessionLow;
private bool _tradedToday;
private bool _skipToday;
private decimal? _stopPrice;
private decimal? _takePrice;
private Unit _takeDistance;
private Unit _stopDistance;
private Order _entryOrder;
private Order _exitOrder;
private Sides _entrySide;
private decimal _entryVolume;
private decimal _entryValue;
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
public decimal TriggerPercent { get => _triggerPercent.Value; set => _triggerPercent.Value = value; }
public RangeFollowerStrategy()
{
Volume = 0.1m;
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
.SetDisplay("Candle Type", "Working timeframe used for session boundaries.", "General");
_triggerPercent = Param(nameof(TriggerPercent), 60m)
.SetRange(10m, 90m)
.SetDisplay("Trigger Percent", "Percentage of daily ATR used as the entry/stop distance.", "Signal");
Trades.TradeAdded += ProcessTrade;
OrderRegistering += order =>
{
if (Position != 0m && order.Side == (Position > 0m ? Sides.Sell : Sides.Buy))
_exitOrder = order;
};
}
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, CandleType), (Security, TimeSpan.FromDays(1).TimeFrame()), (Security, DataType.Level1)];
protected override void OnReseted()
{
base.OnReseted();
_dailyAtr = null;
_bestBid = null;
_bestAsk = null;
_takeDistance = _stopDistance = null;
_entryOrder = _exitOrder = null;
_entrySide = default;
_entryVolume = _entryValue = 0m;
ResetSession(default);
}
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
var atr = new AverageTrueRange { Length = 20 };
SubscribeCandles(TimeSpan.FromDays(1).TimeFrame())
.BindEx(atr, ProcessDailyCandle)
.Start();
foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
{
var quotes = new Subscription(DataType.Level1, Security);
quotes.MarketData.BuildField = field;
SubscribeLevel1(quotes).Bind(ProcessLevel1).Start();
}
var subscription = SubscribeCandles(CandleType);
subscription.Bind(ProcessWorkingCandle).Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawOwnTrades(area);
}
}
private void ProcessDailyCandle(ICandleMessage candle, IIndicatorValue value)
{
if (candle.State != CandleStates.Finished || !value.IsFormed)
return;
_dailyAtr = value.GetValue<decimal>();
}
private void ProcessLevel1(Level1ChangeMessage message)
{
if (message.TryGetDecimal(Level1Fields.BestBidPrice) is decimal bid && bid > 0m)
_bestBid = bid;
if (message.TryGetDecimal(Level1Fields.BestAskPrice) is decimal ask && ask > 0m)
_bestAsk = ask;
if (_bestBid is decimal currentBid && _bestAsk is decimal currentAsk)
{
if (_sessionDate == message.ServerTime.Date)
{
_sessionHigh = Math.Max(_sessionHigh, Math.Max(currentBid, currentAsk));
_sessionLow = Math.Min(_sessionLow, Math.Min(currentBid, currentAsk));
}
if (Position > 0m)
ApplyProtection(currentBid, currentBid);
else if (Position < 0m)
ApplyProtection(currentAsk, currentAsk);
}
EvaluateQuote();
}
private void ProcessWorkingCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var date = candle.OpenTime.Date;
if (_sessionDate != date)
{
if (_sessionDate != default && Position != 0)
Flatten();
ResetSession(date);
_sessionHigh = candle.HighPrice;
_sessionLow = candle.LowPrice;
_sessionAtr = _dailyAtr;
if (_sessionAtr is decimal initialAtr)
_skipToday = _sessionHigh - _sessionLow > initialAtr * TriggerPercent / 100m;
}
else
{
_sessionHigh = Math.Max(_sessionHigh, candle.HighPrice);
_sessionLow = Math.Min(_sessionLow, candle.LowPrice);
}
if (Position != 0)
ApplyProtection(candle.HighPrice, candle.LowPrice);
EvaluateQuote();
}
private void EvaluateQuote()
{
if (_sessionAtr is not decimal atr || _sessionDate == default || CurrentTime.Date != _sessionDate || _tradedToday || _skipToday || Position != 0)
return;
if (IsPending(_entryOrder) || IsPending(_exitOrder) || !IsFormedAndOnlineAndAllowTrading())
return;
if (_bestBid is not decimal bid || _bestAsk is not decimal ask || _sessionLow == 0m || _sessionHigh == 0m)
return;
var trigger = atr * TriggerPercent / 100m;
var residual = atr - trigger;
var longDistance = bid - _sessionLow;
var shortDistance = _sessionHigh - ask;
if (longDistance <= trigger && shortDistance <= trigger)
return;
// If both are true on a wide session, take the side with the larger excursion.
if (longDistance >= shortDistance)
Enter(Sides.Buy, trigger, residual);
else
Enter(Sides.Sell, trigger, residual);
}
private static bool IsPending(Order order)
=> order is not null && order.State is not (OrderStates.Done or OrderStates.Failed);
private void Enter(Sides side, decimal trigger, decimal residual)
{
if (_takeDistance is null)
{
_takeDistance = new Unit(residual);
_stopDistance = new Unit(trigger);
StartProtection(_takeDistance, _stopDistance, useMarketOrders: true, isLocalStop: true);
}
else
{
// The native position controller retains these Unit objects. Reconfigure only while flat.
_takeDistance.Value = residual;
_stopDistance.Value = trigger;
}
_tradedToday = true;
_entrySide = side;
_entryVolume = _entryValue = 0m;
_entryOrder = side == Sides.Buy ? BuyMarket() : SellMarket();
}
private void ProcessTrade(MyTrade trade)
{
if (trade.Order.Side == _entrySide)
{
_entryVolume += trade.Trade.Volume;
_entryValue += trade.Trade.Price * trade.Trade.Volume;
}
else if (_entryVolume > 0m)
{
var closed = Math.Min(_entryVolume, trade.Trade.Volume);
_entryValue -= _entryValue / _entryVolume * closed;
_entryVolume -= closed;
}
if (_entryVolume > 0m)
{
var entry = _entryValue / _entryVolume;
var direction = _entrySide == Sides.Buy ? 1m : -1m;
_stopPrice = entry - direction * _stopDistance.Value;
_takePrice = entry + direction * _takeDistance.Value;
}
else
{
_entryValue = 0m;
_stopPrice = _takePrice = null;
}
}
private void ApplyProtection(decimal high, decimal low)
{
if (Position > 0 &&
((_stopPrice is decimal longStop && low <= longStop) || (_takePrice is decimal longTake && high >= longTake)))
Flatten();
else if (Position < 0 &&
((_stopPrice is decimal shortStop && high >= shortStop) || (_takePrice is decimal shortTake && low <= shortTake)))
Flatten();
}
private void Flatten()
{
if (IsPending(_exitOrder))
return;
if (Position > 0)
_exitOrder = SellMarket(Math.Abs(Position));
else if (Position < 0)
_exitOrder = BuyMarket(Math.Abs(Position));
}
private void ResetSession(DateTime date)
{
_sessionDate = date;
_sessionAtr = null;
_sessionHigh = 0m;
_sessionLow = 0m;
_tradedToday = false;
_skipToday = false;
_stopPrice = null;
_takePrice = null;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo.Indicators")
from System import TimeSpan, Math, Decimal
from StockSharp.Messages import DataType, CandleStates, Level1Fields, Sides, Unit, OrderStates, ITickTradeMessage
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Subscription
from StockSharp.Algo.Indicators import AverageTrueRange
_tick_price = clr.GetClrType(ITickTradeMessage).GetProperty("Price")
_tick_volume = clr.GetClrType(ITickTradeMessage).GetProperty("Volume")
class range_follower_strategy(Strategy):
def __init__(self):
super(range_follower_strategy, self).__init__()
self.Volume = Decimal(0.1)
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(15)))
self._trigger_percent = self.Param("TriggerPercent", 60.0).SetRange(10.0, 90.0)
self._reset_state()
self.Trades.TradeAdded += self._process_trade
self.OrderRegistering += self._track_exit
def GetWorkingSecurities(self):
return [
(self.Security, self._candle_type.Value),
(self.Security, DataType.TimeFrame(TimeSpan.FromDays(1))),
(self.Security, DataType.Level1),
]
def OnReseted(self):
super(range_follower_strategy, self).OnReseted()
self._reset_state()
def _reset_state(self):
self._daily_atr = None
self._best_bid = None
self._best_ask = None
self._take_distance = None
self._stop_distance = None
self._entry_order = None
self._exit_order = None
self._entry_side = Sides.Buy
self._entry_volume = Decimal.Zero
self._entry_value = Decimal.Zero
self._reset_session(None)
def OnStarted2(self, time):
super(range_follower_strategy, self).OnStarted2(time)
atr = AverageTrueRange()
atr.Length = 20
self.SubscribeCandles(DataType.TimeFrame(TimeSpan.FromDays(1))).BindEx(atr, self._process_daily).Start()
for field in (Level1Fields.BestBidPrice, Level1Fields.BestAskPrice):
quotes = Subscription(DataType.Level1, self.Security)
quotes.MarketData.BuildField = field
self.SubscribeLevel1(quotes).Bind(self._process_level1).Start()
self.SubscribeCandles(self._candle_type.Value).Bind(self._process_working).Start()
def _process_daily(self, candle, value):
if candle.State != CandleStates.Finished or not value.IsFormed:
return
self._daily_atr = value.GetValue[Decimal](None)
def _process_level1(self, message):
bid = message.Changes[Level1Fields.BestBidPrice] if message.Changes.ContainsKey(Level1Fields.BestBidPrice) else None
ask = message.Changes[Level1Fields.BestAskPrice] if message.Changes.ContainsKey(Level1Fields.BestAskPrice) else None
if bid is not None and bid > 0:
self._best_bid = bid
if ask is not None and ask > 0:
self._best_ask = ask
if self._best_bid is not None and self._best_ask is not None:
if self._session_date == message.ServerTime.Date:
self._session_high = max(self._session_high, self._best_bid, self._best_ask)
self._session_low = min(self._session_low, self._best_bid, self._best_ask)
if self.Position > 0:
self._apply_protection(self._best_bid, self._best_bid)
elif self.Position < 0:
self._apply_protection(self._best_ask, self._best_ask)
self._evaluate_quote()
def _process_working(self, candle):
if candle.State != CandleStates.Finished:
return
date = candle.OpenTime.Date
high = candle.HighPrice
low = candle.LowPrice
if self._session_date != date:
if self._session_date is not None and self.Position != 0:
self._flatten()
self._reset_session(date)
self._session_high = high
self._session_low = low
self._session_atr = self._daily_atr
if self._session_atr is not None:
self._skip_today = high - low > self._session_atr * Decimal(self._trigger_percent.Value) / Decimal(100)
else:
self._session_high = max(self._session_high, high)
self._session_low = min(self._session_low, low)
if self.Position != 0:
self._apply_protection(high, low)
self._evaluate_quote()
def _evaluate_quote(self):
if (self._session_atr is None or self._session_date is None or
self.CurrentTime.Date != self._session_date or self._traded_today or
self._skip_today or self.Position != 0 or self._best_bid is None or self._best_ask is None or
self._session_low == 0 or self._session_high == 0):
return
if self._is_pending(self._entry_order) or self._is_pending(self._exit_order) or not self.IsFormedAndOnlineAndAllowTrading():
return
trigger = self._session_atr * Decimal(self._trigger_percent.Value) / Decimal(100)
residual = self._session_atr - trigger
long_distance = self._best_bid - self._session_low
short_distance = self._session_high - self._best_ask
if long_distance <= trigger and short_distance <= trigger:
return
if long_distance >= short_distance:
self._enter(Sides.Buy, trigger, residual)
else:
self._enter(Sides.Sell, trigger, residual)
def _is_pending(self, order):
return order is not None and order.State not in (OrderStates.Done, OrderStates.Failed)
def _enter(self, side, trigger, residual):
if self._take_distance is None:
self._take_distance = Unit(residual)
self._stop_distance = Unit(trigger)
self.StartProtection(self._take_distance, self._stop_distance, useMarketOrders=True, isLocalStop=True)
else:
# Preserve Unit references held by the native controller; update only between flat sessions.
self._take_distance.Value = residual
self._stop_distance.Value = trigger
self._traded_today = True
self._entry_side = side
self._entry_volume = self._entry_value = Decimal.Zero
self._entry_order = self.BuyMarket() if side == Sides.Buy else self.SellMarket()
def _track_exit(self, order):
if self.Position != 0 and order.Side == (Sides.Sell if self.Position > 0 else Sides.Buy):
self._exit_order = order
def _process_trade(self, trade):
price = _tick_price.GetValue(trade.Trade)
volume = _tick_volume.GetValue(trade.Trade)
if trade.Order.Side == self._entry_side:
self._entry_volume += volume
self._entry_value += price * volume
elif self._entry_volume > 0:
closed = Math.Min(self._entry_volume, volume)
self._entry_value -= self._entry_value / self._entry_volume * closed
self._entry_volume -= closed
if self._entry_volume > 0:
entry = self._entry_value / self._entry_volume
direction = Decimal(1) if self._entry_side == Sides.Buy else Decimal(-1)
self._stop_price = entry - direction * self._stop_distance.Value
self._take_price = entry + direction * self._take_distance.Value
else:
self._entry_value = Decimal.Zero
self._stop_price = self._take_price = None
def _apply_protection(self, high, low):
if self.Position > 0 and ((self._stop_price is not None and low <= self._stop_price) or
(self._take_price is not None and high >= self._take_price)):
self._flatten()
elif self.Position < 0 and ((self._stop_price is not None and high >= self._stop_price) or
(self._take_price is not None and low <= self._take_price)):
self._flatten()
def _flatten(self):
if self._is_pending(self._exit_order):
return
if self.Position > 0:
self._exit_order = self.SellMarket(Math.Abs(self.Position))
elif self.Position < 0:
self._exit_order = self.BuyMarket(Math.Abs(self.Position))
def _reset_session(self, date):
self._session_date = date
self._session_atr = None
self._session_high = Decimal.Zero
self._session_low = Decimal.Zero
self._traded_today = False
self._skip_today = False
self._stop_price = None
self._take_price = None
def CreateClone(self):
return range_follower_strategy()