区间跟随策略
概览
区间跟随策略在 StockSharp 高级 API 上复现了 MetaTrader 5 专家顾问 “Range Follower”。策略利用日线的平均真实波幅 (ATR) 作为波动性基准,只要价格在当日内远离日内高点或低点达到指定阈值,便开立一次突破交易。ATR 被划分为触发段与剩余段,触发段用于判断是否入场,剩余段用于设置止盈距离,从而完整保留了原始 EA 的设计。
交易逻辑
- 日内波动基准
- 在日线数据上计算 20 周期 ATR,得到当前交易日的参考区间。
TriggerPercent参数把 ATR 拆分为两部分:触发距离(入场阈值)以及剩余距离(止盈目标)。
- 区间跟踪
- 通过日线订阅实时更新当前交易日的最高价与最低价。
- Level1 行情提供最新买一与卖一,用于计算报价相对于日内极值的距离。
- 每日仅一次入场
- 若买一价高于当日最低价的距离超过触发距离,且当天尚未建仓,则以市价买入。
- 若卖一价低于当日最高价的距离超过触发距离,且当天尚未建仓,则以市价卖出。
- 每个交易日只允许一次交易,日切换时会重置该标志。
- 止损与止盈
- 多头仓位的止损设置在入场价下方一个触发距离,止盈设置在入场价上方一个剩余距离。
- 空头仓位的止损设置在入场价上方一个触发距离,止盈设置在入场价下方一个剩余距离。
- 通过 Level1 价格与蜡烛收盘共同监控,一旦触及任一价格立即平仓。
- 日度重置
- 当出现新交易日的第一根蜡烛时,策略会平掉所有持仓,清空内部状态并重新加载 ATR。
- 如果在当日初始化时发现实际区间已经超过触发距离,则当天暂停交易,以复制原 EA 的安全检查。
参数
| 名称 | 默认值 | 说明 |
|---|---|---|
CandleType |
15 分钟蜡烛 | 用于识别交易日边界的工作级别。 |
TriggerPercent |
60 | ATR 中用于触发的百分比,必须处于 10 到 90 之间。 |
Volume |
0.1 | 多空方向的市价下单数量。 |
风险管理
- 止损与止盈均来源于同一 ATR 基准,回报风险比始终为
(100 - TriggerPercent) : TriggerPercent。 - 策略最多仅持有一个仓位,止损或止盈被触发后立即平仓,避免叠加敞口。
- 调用
StartProtection()以启用 StockSharp 的保护机制,便于额外挂载移动止损或组合风控模块。
实现细节
- 通过单独的日线订阅与
AverageTrueRange指标获取 ATR 数值,全部使用高阶Bind接口完成。 - Level1 数据用于还原 EA 以 tick 为驱动的入场与离场判断,买一和卖一价格直接驱动交易决策。
- 交易日边界来自工作级别蜡烛,确保不同交易日历下都能一致地重置策略状态。
- 转换过程中未使用自定义指标缓存或循环历史数据,而是通过字段保存状态以符合项目规范。
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()