在 GitHub 上查看

SMC Hilo MaxMin 突破策略

概述

本策略复刻 MetaTrader 专家顾问 SMC MaxMin at 1200 的行为。到达设定的终端小时 (SetHour) 时,策略会在上一根完结 K线的高点上方挂买入止损,在低点下方挂卖出止损。挂单价格会根据经纪商的最小止损距离自动加上缓冲,距离以点 (pip) 为单位并通过 Security.PriceStep 转换为报价单位。一旦突破触发,另一侧的挂单立刻撤销,持仓则由固定止损、 止盈以及可选的追踪止损共同管理。

相较于原始 MQL4 代码的主要差异:

  • 使用 StockSharp 提供的 BuyStopSellStopBuyLimitSellLimit 等高层 API,而非直接调用 OrderSend
  • 最小止损距离、止损与止盈均以点数输入,通过 Security.PriceStep 自动换算,兼容不同品种的最小跳动单位。
  • 追踪止损仅在浮盈超过设定的追踪距离时才移动,避免频繁改价。
  • 逻辑完全基于蜡烛订阅高层 API,无需手动遍历历史或维护指标缓存。

交易规则

  1. 建仓时刻:当终端时间的小时数等于 SetHour 时,读取上一根完整 K 线的高低点。
  2. 多头进场:在 上一高点 + 最小止损距离 + 一个最小跳动 处挂买入止损单。
  3. 空头进场:在 上一低点 - 最小止损距离 - 一个最小跳动 处挂卖出止损单。
  4. 互斥执行:任意一侧挂单成交后,另一侧挂单立即撤销。
  5. 初始止损:多头止损价为 上一低点 - StopLossPips,空头止损价为 上一高点 + StopLossPips(均会换算为价格)。
  6. 止盈:多头在 入场价 + TakeProfitPips 挂卖出限价,空头在 入场价 - TakeProfitPips 挂买入限价。
  7. 追踪止损:当浮盈大于 TrailingStopPips 时,将止损跟随当前买/卖价,保持相同的点数距离。
  8. 挂单超时:两小时后 (SetHour + 2) 仍未成交的止损挂单会被自动撤销。

参数

名称 说明 默认值
Volume 进场挂单的下单量。 0.1
SetHour 创建突破挂单的终端小时(0–23)。 15
TakeProfitPips 止盈距离(点)。设为 0 表示不下止盈单。 500
StopLossPips 初始止损距离(点)。设为 0 表示不下初始止损。 30
TrailingStopPips 追踪止损距离(点)。设为 0 表示不启用追踪。 30
MinStopDistancePips 经纪商要求的最小止损距离(点),用于调整挂单价。 0
CandleType 用于判断小时窗口的蜡烛类型,默认 1 小时。 1h

使用提示

  • 需订阅 Level-1 数据,以便获取最新买/卖价用于追踪止损和距离换算。
  • 若标的资产的最小跳动与常见外汇品种不同,请相应调整 TakeProfitPipsStopLossPipsTrailingStopPips
  • 当止盈或止损参数为 0 时,不会发送对应的挂单,但若启用追踪止损仍会根据浮盈动态调整。
  • 请确认 SetHour 与行情源提供的服务器时间一致,避免错过预期的挂单时刻。
using System;
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>
/// Breakout straddle that mirrors the "SMC MaxMin" MetaTrader expert.
/// Places stop orders around the previous bar's extremes at a chosen hour
/// and manages protective stop and take-profit levels with trailing updates.
/// </summary>
public class SmcHiloMaxMinStrategy : Strategy
{
	private readonly StrategyParam<int> _setHour;
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<decimal> _stopLossPips;
	private readonly StrategyParam<decimal> _trailingStopPips;
	private readonly StrategyParam<decimal> _minStopDistancePips;
	private readonly StrategyParam<DataType> _candleType;

	private ICandleMessage _previousCandle;
	private DateTime? _lastSetupDate;

	private Order _buyStopOrder;
	private Order _sellStopOrder;
	private Order _longStopOrder;
	private Order _longTakeProfitOrder;
	private Order _shortStopOrder;
	private Order _shortTakeProfitOrder;

	private decimal? _bestBid;
	private decimal? _bestAsk;

	private decimal? _longEntryPrice;
	private decimal? _shortEntryPrice;
	private decimal? _longStopPrice;
	private decimal? _shortStopPrice;
	private decimal? _longTargetPrice;
	private decimal? _shortTargetPrice;
	private decimal? _pendingLongStop;
	private decimal? _pendingShortStop;
	private decimal? _pendingLongTarget;
	private decimal? _pendingShortTarget;

	private decimal _pipSize;

	/// <summary>
	/// Terminal hour when the breakout straddle is placed.
	/// </summary>
	public int SetHour
	{
		get => _setHour.Value;
		set => _setHour.Value = value;
	}

	/// <summary>
	/// Take-profit distance expressed in pips.
	/// </summary>
	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in pips.
	/// </summary>
	public decimal StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Trailing-stop distance expressed in pips.
	/// </summary>
	public decimal TrailingStopPips
	{
		get => _trailingStopPips.Value;
		set => _trailingStopPips.Value = value;
	}

	/// <summary>
	/// Minimum broker stop distance in pips.
	/// </summary>
	public decimal MinStopDistancePips
	{
		get => _minStopDistancePips.Value;
		set => _minStopDistancePips.Value = value;
	}

	/// <summary>
	/// Candle type used to evaluate the hourly session.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initialize strategy parameters with sensible defaults.
	/// </summary>
	public SmcHiloMaxMinStrategy()
	{

		_setHour = Param(nameof(SetHour), 15)
		.SetDisplay("Trigger Hour", "Terminal hour when pending orders are created", "Timing");

		_takeProfitPips = Param(nameof(TakeProfitPips), 500m)
		.SetNotNegative()
		.SetDisplay("Take Profit (pips)", "Distance from entry to the profit target", "Risk");

		_stopLossPips = Param(nameof(StopLossPips), 30m)
		.SetNotNegative()
		.SetDisplay("Stop Loss (pips)", "Distance from entry to the protective stop", "Risk");

		_trailingStopPips = Param(nameof(TrailingStopPips), 30m)
		.SetNotNegative()
		.SetDisplay("Trailing Stop (pips)", "Trailing stop distance that replaces the static stop", "Risk");

		_minStopDistancePips = Param(nameof(MinStopDistancePips), 0m)
		.SetNotNegative()
		.SetDisplay("Min Stop Distance (pips)", "Broker minimum stop distance, used to pad breakout levels", "Timing");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Candles that define the hourly breakout window", "Timing");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

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

		_previousCandle = null;
		_lastSetupDate = null;

		_buyStopOrder = null;
		_sellStopOrder = null;
		_longStopOrder = null;
		_longTakeProfitOrder = null;
		_shortStopOrder = null;
		_shortTakeProfitOrder = null;

		_bestBid = null;
		_bestAsk = null;

		_longEntryPrice = null;
		_shortEntryPrice = null;
		_longStopPrice = null;
		_shortStopPrice = null;
		_longTargetPrice = null;
		_shortTargetPrice = null;
		_pendingLongStop = null;
		_pendingShortStop = null;
		_pendingLongTarget = null;
		_pendingShortTarget = null;

		_pipSize = 0m;
	}

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

		UpdatePipSize();

		var candleSubscription = SubscribeCandles(CandleType);
		candleSubscription
		.Bind(ProcessCandle)
		.Start();

		SubscribeLevel1()
		.Bind(ProcessLevel1)
		.Start();
	}

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

		UpdatePipSize();

		var hour = candle.OpenTime.Hour;
		var currentDate = candle.OpenTime.Date;

		if (_previousCandle != null)
		{
			if (_lastSetupDate != currentDate && hour == NormalizeHour(SetHour))
			{
				PlaceStraddle(candle.OpenTime);
			}

			var cancelHour = NormalizeHour(SetHour + 2);
			if (_lastSetupDate == currentDate && hour == cancelHour)
			{
				CancelEntryOrders();
			}
		}

		_previousCandle = candle;
	}

	private void ProcessLevel1(Level1ChangeMessage message)
	{
		var bid = message.TryGetDecimal(Level1Fields.BestBidPrice);
		var ask = message.TryGetDecimal(Level1Fields.BestAskPrice);

		if (bid.HasValue && bid.Value > 0m)
		_bestBid = bid.Value;

		if (ask.HasValue && ask.Value > 0m)
		_bestAsk = ask.Value;

		CleanupInactiveOrders();
		ManageActivePosition();
	}

	private void PlaceStraddle(DateTimeOffset triggerTime)
	{
		if (_previousCandle == null)
		return;

		if (Volume <= 0m)
		return;

		if (Position != 0m)
		return;

		if (IsOrderActive(_buyStopOrder) || IsOrderActive(_sellStopOrder))
		return;

		var previousHigh = _previousCandle.HighPrice;
		var previousLow = _previousCandle.LowPrice;

		if (previousHigh <= 0m || previousLow <= 0m)
		return;

		var priceStep = GetPriceStep();
		var minDistance = MinStopDistancePips * _pipSize;
		var ask = _bestAsk ?? _previousCandle.ClosePrice;
		var bid = _bestBid ?? _previousCandle.ClosePrice;

		var longTrigger = previousHigh;
		if (minDistance > 0m && ask > 0m)
		{
			var distance = previousHigh - ask;
			if (distance < minDistance)
			longTrigger += minDistance - distance;
		}

		var shortTrigger = previousLow;
		if (minDistance > 0m && bid > 0m)
		{
			var distance = bid - previousLow;
			if (distance < minDistance)
			shortTrigger -= minDistance - distance;
		}

		longTrigger = NormalizePrice(longTrigger + priceStep);
		shortTrigger = NormalizePrice(shortTrigger - priceStep);

		if (longTrigger > 0m)
		{
			CancelOrderIfActive(ref _buyStopOrder);
			_buyStopOrder = BuyMarket(Volume);

			_pendingLongStop = CalculateLongStopPrice();
			_pendingLongTarget = CalculateLongTargetPrice(longTrigger);
		}

		if (shortTrigger > 0m)
		{
			CancelOrderIfActive(ref _sellStopOrder);
			_sellStopOrder = SellMarket(Volume);

			_pendingShortStop = CalculateShortStopPrice();
			_pendingShortTarget = CalculateShortTargetPrice(shortTrigger);
		}

		if (_buyStopOrder != null || _sellStopOrder != null)
		_lastSetupDate = triggerTime.Date;
	}

	private decimal? CalculateLongStopPrice()
	{
		if (_previousCandle == null)
		return null;

		var distance = StopLossPips * _pipSize;
		if (distance <= 0m)
		return null;

		var stop = _previousCandle.LowPrice - distance;
		return stop > 0m ? NormalizePrice(stop) : (decimal?)null;
	}

	private decimal? CalculateShortStopPrice()
	{
		if (_previousCandle == null)
		return null;

		var distance = StopLossPips * _pipSize;
		if (distance <= 0m)
		return null;

		var stop = _previousCandle.HighPrice + distance;
		return stop > 0m ? NormalizePrice(stop) : (decimal?)null;
	}

	private decimal? CalculateLongTargetPrice(decimal entryPrice)
	{
		var distance = TakeProfitPips * _pipSize;
		if (distance <= 0m)
		return null;

		var target = entryPrice + distance;
		return target > 0m ? NormalizePrice(target) : (decimal?)null;
	}

	private decimal? CalculateShortTargetPrice(decimal entryPrice)
	{
		var distance = TakeProfitPips * _pipSize;
		if (distance <= 0m)
		return null;

		var target = entryPrice - distance;
		return target > 0m ? NormalizePrice(target) : (decimal?)null;
	}

	private void ManageActivePosition()
	{
		if (Position > 0m)
		{
			EnsureLongProtection();
			UpdateLongTrailing();
		}
		else if (Position < 0m)
		{
			EnsureShortProtection();
			UpdateShortTrailing();
		}
	}

	private void EnsureLongProtection()
	{
		var volume = Math.Abs(Position);
		if (volume <= 0m)
		return;

		if (_longStopPrice is decimal stop && stop > 0m)
		{
			var normalized = NormalizePrice(stop);
			if (_longStopOrder == null || !ArePricesEqual(_longStopOrder.Price, normalized))
			{
				CancelOrderIfActive(ref _longStopOrder);
				_longStopOrder = SellMarket(volume);
			}
		}
		else
		{
			CancelOrderIfActive(ref _longStopOrder);
		}

		if (_longTargetPrice is decimal target && target > 0m)
		{
			var normalized = NormalizePrice(target);
			if (_longTakeProfitOrder == null || !ArePricesEqual(_longTakeProfitOrder.Price, normalized))
			{
				CancelOrderIfActive(ref _longTakeProfitOrder);
				_longTakeProfitOrder = SellMarket(volume);
			}
		}
		else
		{
			CancelOrderIfActive(ref _longTakeProfitOrder);
		}
	}

	private void EnsureShortProtection()
	{
		var volume = Math.Abs(Position);
		if (volume <= 0m)
		return;

		if (_shortStopPrice is decimal stop && stop > 0m)
		{
			var normalized = NormalizePrice(stop);
			if (_shortStopOrder == null || !ArePricesEqual(_shortStopOrder.Price, normalized))
			{
				CancelOrderIfActive(ref _shortStopOrder);
				_shortStopOrder = BuyMarket(volume);
			}
		}
		else
		{
			CancelOrderIfActive(ref _shortStopOrder);
		}

		if (_shortTargetPrice is decimal target && target > 0m)
		{
			var normalized = NormalizePrice(target);
			if (_shortTakeProfitOrder == null || !ArePricesEqual(_shortTakeProfitOrder.Price, normalized))
			{
				CancelOrderIfActive(ref _shortTakeProfitOrder);
				_shortTakeProfitOrder = BuyMarket(volume);
			}
		}
		else
		{
			CancelOrderIfActive(ref _shortTakeProfitOrder);
		}
	}

	private void UpdateLongTrailing()
	{
		if (TrailingStopPips <= 0m)
		return;

		if (_longEntryPrice is not decimal entry)
		return;

		var bid = _bestBid ?? 0m;
		if (bid <= 0m)
		return;

		var distance = TrailingStopPips * _pipSize;
		if (distance <= 0m)
		return;

		var profit = bid - entry;
		if (profit <= distance)
		return;

		var newStop = NormalizePrice(bid - distance);
		if (_longStopPrice is decimal existing && !IsGreaterThan(newStop, existing))
		return;

		_longStopPrice = newStop;
		EnsureLongProtection();
	}

	private void UpdateShortTrailing()
	{
		if (TrailingStopPips <= 0m)
		return;

		if (_shortEntryPrice is not decimal entry)
		return;

		var ask = _bestAsk ?? 0m;
		if (ask <= 0m)
		return;

		var distance = TrailingStopPips * _pipSize;
		if (distance <= 0m)
		return;

		var profit = entry - ask;
		if (profit <= distance)
		return;

		var newStop = NormalizePrice(ask + distance);
		if (_shortStopPrice is decimal existing && !IsLessThan(newStop, existing))
		return;

		_shortStopPrice = newStop;
		EnsureShortProtection();
	}

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		if (trade.Order.Security != Security)
		return;

		var tradeVolume = trade.Trade.Volume;
		if (tradeVolume <= 0m)
		return;

		var signedDelta = trade.Order.Side == Sides.Buy ? tradeVolume : -tradeVolume;
		var currentPosition = Position;
		var previousPosition = currentPosition - signedDelta;

		if (currentPosition > 0m && trade.Order.Side == Sides.Buy)
		{
			UpdateLongEntry(previousPosition, trade.Trade.Price, tradeVolume);
		}
		else if (currentPosition < 0m && trade.Order.Side == Sides.Sell)
		{
			UpdateShortEntry(previousPosition, trade.Trade.Price, tradeVolume);
		}
		else
		{
			if (previousPosition > 0m && currentPosition <= 0m)
			ResetLongState();

			if (previousPosition < 0m && currentPosition >= 0m)
			ResetShortState();
		}

		if (trade.Order == _buyStopOrder)
		{
			_buyStopOrder = null;
			CancelOrderIfActive(ref _sellStopOrder);
		}
		else if (trade.Order == _sellStopOrder)
		{
			_sellStopOrder = null;
			CancelOrderIfActive(ref _buyStopOrder);
		}

		if (trade.Order == _longStopOrder || trade.Order == _longTakeProfitOrder)
		{
			ResetLongState();
		}

		if (trade.Order == _shortStopOrder || trade.Order == _shortTakeProfitOrder)
		{
			ResetShortState();
		}

	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		if (Position == 0m)
		{
			ResetLongState();
			ResetShortState();
		}
	}

	private void UpdateLongEntry(decimal previousPosition, decimal price, decimal tradeVolume)
	{
		var positionBefore = Math.Abs(previousPosition);
		var currentPosition = Math.Abs(Position);

		if (positionBefore <= 0m)
		{
			_longEntryPrice = price;
		}
		else if (_longEntryPrice is decimal existing)
		{
			_longEntryPrice = (existing * positionBefore + price * tradeVolume) / currentPosition;
		}
		else
		{
			_longEntryPrice = price;
		}

		_longStopPrice = _pendingLongStop;
		_longTargetPrice = _pendingLongTarget;

		EnsureLongProtection();
	}

	private void UpdateShortEntry(decimal previousPosition, decimal price, decimal tradeVolume)
	{
		var positionBefore = Math.Abs(previousPosition);
		var currentPosition = Math.Abs(Position);

		if (positionBefore <= 0m)
		{
			_shortEntryPrice = price;
		}
		else if (_shortEntryPrice is decimal existing)
		{
			_shortEntryPrice = (existing * positionBefore + price * tradeVolume) / currentPosition;
		}
		else
		{
			_shortEntryPrice = price;
		}

		_shortStopPrice = _pendingShortStop;
		_shortTargetPrice = _pendingShortTarget;

		EnsureShortProtection();
	}

	private void ResetLongState()
	{
		CancelOrderIfActive(ref _longStopOrder);
		CancelOrderIfActive(ref _longTakeProfitOrder);

		_longEntryPrice = null;
		_longStopPrice = null;
		_longTargetPrice = null;
		_pendingLongStop = null;
		_pendingLongTarget = null;
	}

	private void ResetShortState()
	{
		CancelOrderIfActive(ref _shortStopOrder);
		CancelOrderIfActive(ref _shortTakeProfitOrder);

		_shortEntryPrice = null;
		_shortStopPrice = null;
		_shortTargetPrice = null;
		_pendingShortStop = null;
		_pendingShortTarget = null;
	}

	private void CancelEntryOrders()
	{
		CancelOrderIfActive(ref _buyStopOrder);
		CancelOrderIfActive(ref _sellStopOrder);
	}

	private void CleanupInactiveOrders()
	{
		CleanupOrder(ref _buyStopOrder);
		CleanupOrder(ref _sellStopOrder);
		CleanupOrder(ref _longStopOrder);
		CleanupOrder(ref _longTakeProfitOrder);
		CleanupOrder(ref _shortStopOrder);
		CleanupOrder(ref _shortTakeProfitOrder);
	}

	private void CleanupOrder(ref Order order)
	{
		if (order == null)
		return;

		if (!IsOrderActive(order))
		order = null;
	}

	private void CancelOrderIfActive(ref Order order)
	{
		if (order == null)
		return;

		if (IsOrderActive(order))
		CancelOrder(order);

		order = null;
	}

	private static bool IsOrderActive(Order order)
	{
		return order != null && order.State == OrderStates.Active;
	}

	private int NormalizeHour(int hour)
	{
		if (hour < 0)
		hour = 0;

		return ((hour % 24) + 24) % 24;
	}

	private void UpdatePipSize()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step <= 0m)
		return;

		var digits = GetDecimalDigits(step);
		_pipSize = (digits == 3 || digits == 5) ? step * 10m : step;
	}

	private decimal GetPriceStep()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step > 0m)
		{
			return step;
		}

		if (_pipSize > 0m)
		{
			return _pipSize;
		}

		return 0.0001m;
	}

	private decimal NormalizePrice(decimal price)
	{
		if (price <= 0m)
		{
			return price;
		}

		var step = Security?.PriceStep;
		if (step == null || step.Value <= 0m)
		{
			return price;
		}

		var steps = Math.Round(price / step.Value, MidpointRounding.AwayFromZero);
		return steps * step.Value;
	}

	private static int GetDecimalDigits(decimal value)
	{
		value = Math.Abs(value);
		var digits = 0;

		while (value != Math.Truncate(value) && digits < 10)
		{
			value *= 10m;
			digits++;
		}

		return digits;
	}

	private bool ArePricesEqual(decimal first, decimal second)
	{
		var step = GetPriceStep();
		if (step <= 0m)
		{
			step = 0.0000001m;
		}

		return Math.Abs(first - second) <= step / 2m;
	}

	private bool IsGreaterThan(decimal candidate, decimal reference)
	{
		var step = GetPriceStep();
		return candidate > reference + step / 2m;
	}

	private bool IsLessThan(decimal candidate, decimal reference)
	{
		var step = GetPriceStep();
		return candidate < reference - step / 2m;
	}
}