在 GitHub 上查看

区间跟随策略

概览

区间跟随策略在 StockSharp 高级 API 上复现了 MetaTrader 5 专家顾问 “Range Follower”。策略利用日线的平均真实波幅 (ATR) 作为波动性基准,只要价格在当日内远离日内高点或低点达到指定阈值,便开立一次突破交易。ATR 被划分为触发段与剩余段,触发段用于判断是否入场,剩余段用于设置止盈距离,从而完整保留了原始 EA 的设计。

交易逻辑

  1. 日内波动基准
    • 在日线数据上计算 20 周期 ATR,得到当前交易日的参考区间。
    • TriggerPercent 参数把 ATR 拆分为两部分:触发距离(入场阈值)以及剩余距离(止盈目标)。
  2. 区间跟踪
    • 通过日线订阅实时更新当前交易日的最高价与最低价。
    • Level1 行情提供最新买一与卖一,用于计算报价相对于日内极值的距离。
  3. 每日仅一次入场
    • 若买一价高于当日最低价的距离超过触发距离,且当天尚未建仓,则以市价买入。
    • 若卖一价低于当日最高价的距离超过触发距离,且当天尚未建仓,则以市价卖出。
    • 每个交易日只允许一次交易,日切换时会重置该标志。
  4. 止损与止盈
    • 多头仓位的止损设置在入场价下方一个触发距离,止盈设置在入场价上方一个剩余距离。
    • 空头仓位的止损设置在入场价上方一个触发距离,止盈设置在入场价下方一个剩余距离。
    • 通过 Level1 价格与蜡烛收盘共同监控,一旦触及任一价格立即平仓。
  5. 日度重置
    • 当出现新交易日的第一根蜡烛时,策略会平掉所有持仓,清空内部状态并重新加载 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;
	}
}