在 GitHub 上查看

楼梯策略

楼梯策略 完全复刻了最初的 MetaTrader 专家顾问。策略先在当前卖价上下放置对称的止损挂单,然后始终围绕最近一次成交重新搭建网格。未实现利润按照价格跳动(点)累积,而不按照成交量加权,这与源码的做法一致。当达到利润目标时,策略会通过市价单平掉全部仓位,删除所有挂单并重新开始。

交易逻辑

  1. 当没有持仓时,在当前卖价之上和之下 ChannelSteps / 2 个最小价位处分别挂出买入止损和卖出止损单。
  2. 首单成交后围绕成交价重建网格:
    • 如果激活的止损挂单不足两张,就取消旧单。
    • 只要当前买价仍在距最后成交价一半通道距离内,就重新在 ChannelSteps 的距离外放置买入止损和卖出止损单。
    • AddLots 为真时,每次成交后都会在下一组挂单中叠加初始手数。
  3. 维护多头和空头持仓列表,以重现 MT4 版本使用的对冲式仓位篮子。
  4. 在每根收盘 K 线处,使用最佳买价计算多头利润,使用最佳卖价计算空头利润,并按照品种的最小价格跳动归一化距离,从而精确复刻原始的点值计算。
  5. 当以下任一阈值被突破时触发总清仓:
    • ProfitSteps – 仅统计当前品种产生的利润。
    • CommonProfitSteps – 统计整个仓位篮子的利润。
  6. 清仓时分别发送市价单平掉所有多头和空头头寸,并在仓位归零后撤销剩余的止损挂单。

注意:原始脚本在挂单时附加了止损价位。StockSharp 的高级 API 无法针对单笔挂单附加保护性止损,因此移植版完全依赖上述利润条件来退出交易。

参数

参数 说明 默认值
ChannelSteps 对称止损挂单之间的距离,单位为最小价格跳动。 1000
ProfitSteps 达到该点数增益后平掉当前品种的仓位篮子。 1500
CommonProfitSteps 全部仓位篮子的全局利润阈值,超过后强制清仓。 1000
AddLots 若为真,每次成交后在下一组挂单中追加初始手数。 true
BaseVolume 第一组止损挂单的下单手数。 0.1m
CandleType 订阅及管理所使用的时间框架。 1 minute

实现说明

  • 通过 SubscribeCandles()Bind() 使用 StockSharp 的高级 API,仅处理收盘后的 K 线。
  • OnOwnTradeReceived 中跟踪单笔成交,以便利润计算能够复现 MQL 版本的对冲逻辑。
  • 利润阈值完全基于价格跳动距离,不与成交量相乘,与 MT4 脚本的点值累加方式保持一致。
  • 通过 BuyStopSellStop 创建所有挂单,并使用市价单执行平仓,从而保证跨数据源的可移植性。
using System;

using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Stairs grid strategy: places trades at regular ATR-based intervals,
/// adding to position on trending moves, closing on profit target.
/// </summary>
public class StairsStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _atrLength;
	private readonly StrategyParam<decimal> _gridMultiplier;
	private readonly StrategyParam<int> _maxLayers;
	private readonly StrategyParam<decimal> _profitMultiplier;
	private readonly StrategyParam<int> _emaLength;

	private decimal _entryPrice;
	private decimal _lastGridPrice;
	private int _gridCount;
	private decimal _prevEma;

	public StairsStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(30).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe.", "General");

		_atrLength = Param(nameof(AtrLength), 14)
			.SetDisplay("ATR Length", "ATR period for grid step.", "Indicators");

		_gridMultiplier = Param(nameof(GridMultiplier), 1.5m)
			.SetDisplay("Grid Multiplier", "ATR multiplier for grid step.", "Grid");

		_maxLayers = Param(nameof(MaxLayers), 5)
			.SetDisplay("Max Layers", "Maximum grid layers.", "Grid");

		_profitMultiplier = Param(nameof(ProfitMultiplier), 2.0m)
			.SetDisplay("Profit Multiplier", "ATR multiplier for profit target.", "Grid");

		_emaLength = Param(nameof(EmaLength), 20)
			.SetDisplay("EMA Length", "EMA for trend direction.", "Indicators");
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public int AtrLength
	{
		get => _atrLength.Value;
		set => _atrLength.Value = value;
	}

	public decimal GridMultiplier
	{
		get => _gridMultiplier.Value;
		set => _gridMultiplier.Value = value;
	}

	public int MaxLayers
	{
		get => _maxLayers.Value;
		set => _maxLayers.Value = value;
	}

	public decimal ProfitMultiplier
	{
		get => _profitMultiplier.Value;
		set => _profitMultiplier.Value = value;
	}

	public int EmaLength
	{
		get => _emaLength.Value;
		set => _emaLength.Value = value;
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();

		_entryPrice = 0;
		_lastGridPrice = 0;
		_gridCount = 0;
		_prevEma = 0;
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		var atr = new AverageTrueRange { Length = AtrLength };
		var ema = new ExponentialMovingAverage { Length = EmaLength };

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(atr, ema, ProcessCandle)
			.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawIndicator(area, ema);
			DrawOwnTrades(area);
		}
	}

	private void ProcessCandle(ICandleMessage candle, decimal atrVal, decimal emaVal)
	{
		if (candle.State != CandleStates.Finished)
			return;

		if (atrVal <= 0 || _prevEma == 0)
		{
			_prevEma = emaVal;
			return;
		}

		var close = candle.ClosePrice;
		var gridStep = atrVal * GridMultiplier;
		var profitTarget = atrVal * ProfitMultiplier;

		// Check profit target
		if (Position > 0 && _entryPrice > 0)
		{
			if (close - _entryPrice >= profitTarget || close < emaVal)
			{
				SellMarket();
				_gridCount = 0;
				_entryPrice = 0;
				_lastGridPrice = 0;
			}
		}
		else if (Position < 0 && _entryPrice > 0)
		{
			if (_entryPrice - close >= profitTarget || close > emaVal)
			{
				BuyMarket();
				_gridCount = 0;
				_entryPrice = 0;
				_lastGridPrice = 0;
			}
		}

		// Grid: add to winning direction
		if (Position > 0 && _lastGridPrice > 0 && _gridCount < MaxLayers)
		{
			if (close - _lastGridPrice >= gridStep)
			{
				BuyMarket();
				_lastGridPrice = close;
				_gridCount++;
			}
		}
		else if (Position < 0 && _lastGridPrice > 0 && _gridCount < MaxLayers)
		{
			if (_lastGridPrice - close >= gridStep)
			{
				SellMarket();
				_lastGridPrice = close;
				_gridCount++;
			}
		}

		// Initial entry based on trend
		if (Position == 0)
		{
			var emaRising = emaVal > _prevEma;
			var emaFalling = emaVal < _prevEma;

			if (emaRising && close > emaVal)
			{
				_entryPrice = close;
				_lastGridPrice = close;
				_gridCount = 0;
				BuyMarket();
			}
			else if (emaFalling && close < emaVal)
			{
				_entryPrice = close;
				_lastGridPrice = close;
				_gridCount = 0;
				SellMarket();
			}
		}

		_prevEma = emaVal;
	}
}