在 GitHub 上查看

SendClose 策略

概述

SendClose 是一套基于分形支撑/阻力的突破系统,来源于 MT5 平台的同名专家顾问。策略通过连接交替出现的上/下分形来构建动态趋势线,一旦价格重新触及这些投射出来的水平,就会执行开仓或平仓操作。此移植版完全基于 StockSharp 的高级 API,实现了与原版相同的交易规则:

  • 使用最近的分形节点绘制卖出线与买入线;
  • 价格触线后采用市价单入场;
  • 通过在趋势线上下方平移固定点数生成 Close1/Close2 退出线,用于强制平仓。

分形识别流程

  1. 五根K线窗口:策略维护最近五根已完成的K线缓冲区,当数据充足时,总是检查位于中间的那根K线。
  2. 上分形条件:若中间K线的最高价高于其两根更早K线,并且不低于两根更晚K线的最高价,则确认一个上分形,与 MT5 的 iFractals 指标保持一致。
  3. 下分形条件:若中间K线的最低价低于其两根更早K线,并且不高于两根更晚K线的最低价,则确认一个下分形。
  4. 分形队列:每次确认新的分形后,都会压入一个最多六个元素的队列中,按时间从新到旧排列,供趋势线构建逻辑检索。

趋势线构建

  • 卖出线:寻找最近一次“上分形 → 下分形 → 上分形”的组合,并将两端的上分形连接成直线,对应阻力线。
  • 买入线:寻找最近一次“下分形 → 上分形 → 下分形”的组合,并连接两端的下分形,形成支撑线。
  • 价格投射:保存分形点的时间与价格后,可在任何未来时刻对直线进行插值或外推,得到当前蜡烛收盘时间的理论价格。
  • 退出线:在卖出线上方、买入线下方分别平移 LineOffsetSteps × PriceStep,生成 Close1 与 Close2,用于复制原策略的强制平仓机制。

交易逻辑

入场

  • 卖出:当价格触及卖出线,且当前不存在多头头寸时,发送卖出市价单。若已经持有空头仓位,可在不超过 MaxPositions 限额的前提下加仓。
  • 买入:当价格触及买入线,且当前不存在空头头寸时,发送买入市价单。同样允许在限额内逐步加仓。

离场

  • Close1/Close2:价格触碰任一退出线时,立即平掉全部持仓,与 MT5 版本保持一致。
  • 净额处理:由于 StockSharp 采用净头寸模型,策略会在信号触发时先尝试平掉反向仓位,再决定是否开立新方向的头寸。

触线判定

原版在报价触及直线时即时反应,此实现使用蜡烛的高低价区间来近似。如果需要更高精度,可改用逐笔数据订阅。

参数说明

参数 说明
EnableSellLine 是否根据上方分形线执行卖出入场。
EnableBuyLine 是否根据下方分形线执行买入入场。
EnableCloseSellLine 是否启用 Close1(卖出线向上平移后的平仓线)。
EnableCloseBuyLine 是否启用 Close2(买入线向下平移后的平仓线)。
MaxPositions 允许同时持有的最大净头寸(以下单手数为单位)。
OrderVolume 每次市价单的下单数量。
LineOffsetSteps Close1/Close2 与基础趋势线之间的价格步长偏移,默认 15,对应 MT5 中的 15*Point()
CandleType 用于计算的K线类型(时间框架)。

实现细节

  • 仅处理已完成的K线,避免在未确认的蜡烛上产生虚假分形。
  • 退出逻辑优先于入场逻辑,保证触发平仓时不会同时再开仓。
  • MaxPositions 基于净仓计算,因此在净额账户中可直接限制加仓数量;若需要完全复制 MT5 的对冲模式,可将该值设置为更大并在交易所层面允许对冲。
  • 偏移量依赖 Security.PriceStep。若交易品种未提供价格步长,请手动设置或在外部补充。

使用建议

  1. 在 StockSharp 终端中选定交易品种,确认其最小价格变动(PriceStep)与合约规模信息完整。
  2. CandleType 设置为与图表一致的时间框架,如 M15 或 H1,以获得与原策略相近的信号节奏。
  3. 根据资金管理需求调整 OrderVolumeMaxPositions,控制最大敞口。
  4. 如果市场波动较大,可适当提高 LineOffsetSteps,以减少噪音触发的平仓。
  5. 建议结合账户级风控(如日内止损、交易时段过滤)一起使用,防止无保护的市价单造成过度亏损。

与 MT5 版本的差异

  • 新版本不自动绘制图形对象,如需可视化,可在图表模块中自行绘制趋势线。
  • 使用蜡烛高低价替代买卖价判断触线,信号可能比 MT5 的逐笔触发略有延迟。
  • 多空互转时会优先平仓再入场,避免在净额账户里出现双向持仓。

风险提示

该策略未内置止损或资金管理规则,仅依赖分形线触发。实盘前务必在目标品种上进行充分回测与前向验证,并辅以账户级风险控制措施。

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>
/// SendClose strategy replicates fractal breakout lines with close-based exits.
/// This class recreates the MT5 SendClose expert using StockSharp high level API.
/// </summary>
public class SendCloseStrategy : Strategy
{
	private enum FractalTypes
	{
		Up,
		Down
	}

	private readonly struct FractalPoint
	{
		public FractalPoint(FractalTypes type, DateTimeOffset time, decimal price)
		{
			Type = type;
			Time = time;
			Price = price;
		}

		public FractalTypes Type { get; }
		public DateTimeOffset Time { get; }
		public decimal Price { get; }
	}

	private readonly struct FractalLine
	{
		public FractalLine(FractalPoint recent, FractalPoint older)
		{
			if (recent.Time < older.Time)
			{
				Recent = older;
				Older = recent;
			}
			else
			{
				Recent = recent;
				Older = older;
			}
		}

		public FractalPoint Recent { get; }
		public FractalPoint Older { get; }

		public decimal GetPrice(DateTimeOffset time)
		{
			var totalSeconds = (decimal)(Recent.Time - Older.Time).TotalSeconds;
			if (totalSeconds == 0m)
				return Recent.Price;

			var offsetSeconds = (decimal)(time - Older.Time).TotalSeconds;
			return Older.Price + (Recent.Price - Older.Price) * (offsetSeconds / totalSeconds);
		}
	}

	private readonly StrategyParam<bool> _enableSellLine;
	private readonly StrategyParam<bool> _enableBuyLine;
	private readonly StrategyParam<bool> _enableCloseSellLine;
	private readonly StrategyParam<bool> _enableCloseBuyLine;
	private readonly StrategyParam<int> _maxPositions;
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _lineOffsetSteps;
	private readonly StrategyParam<DataType> _candleType;

	private decimal _h0;
	private decimal _h1;
	private decimal _h2;
	private decimal _h3;
	private decimal _h4;

	private decimal _l0;
	private decimal _l1;
	private decimal _l2;
	private decimal _l3;
	private decimal _l4;

	private DateTimeOffset _t0;
	private DateTimeOffset _t1;
	private DateTimeOffset _t2;
	private DateTimeOffset _t3;
	private DateTimeOffset _t4;

	private int _bufferCount;

	private FractalPoint? _fractal0;
	private FractalPoint? _fractal1;
	private FractalPoint? _fractal2;
	private FractalPoint? _fractal3;
	private FractalPoint? _fractal4;
	private FractalPoint? _fractal5;

	private FractalLine? _sellLine;
	private FractalLine? _buyLine;

	/// <summary>
	/// Enable sell breakout line.
	/// </summary>
	public bool EnableSellLine
	{
		get => _enableSellLine.Value;
		set => _enableSellLine.Value = value;
	}

	/// <summary>
	/// Enable buy breakout line.
	/// </summary>
	public bool EnableBuyLine
	{
		get => _enableBuyLine.Value;
		set => _enableBuyLine.Value = value;
	}

	/// <summary>
	/// Enable upper close line (based on sell trend line).
	/// </summary>
	public bool EnableCloseSellLine
	{
		get => _enableCloseSellLine.Value;
		set => _enableCloseSellLine.Value = value;
	}

	/// <summary>
	/// Enable lower close line (based on buy trend line).
	/// </summary>
	public bool EnableCloseBuyLine
	{
		get => _enableCloseBuyLine.Value;
		set => _enableCloseBuyLine.Value = value;
	}

	/// <summary>
	/// Maximum number of lots that can remain open.
	/// </summary>
	public int MaxPositions
	{
		get => _maxPositions.Value;
		set => _maxPositions.Value = value;
	}

	/// <summary>
	/// Order volume per entry.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Offset in price steps for close lines.
	/// </summary>
	public int LineOffsetSteps
	{
		get => _lineOffsetSteps.Value;
		set => _lineOffsetSteps.Value = value;
	}

	/// <summary>
	/// Candle type used for analysis.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initialize <see cref="SendCloseStrategy"/>.
	/// </summary>
	public SendCloseStrategy()
	{
		_enableSellLine = Param(nameof(EnableSellLine), true)
			.SetDisplay("Sell Line", "Enable sell fractal breakout line", "General");

		_enableBuyLine = Param(nameof(EnableBuyLine), true)
			.SetDisplay("Buy Line", "Enable buy fractal breakout line", "General");

		_enableCloseSellLine = Param(nameof(EnableCloseSellLine), true)
			.SetDisplay("Close Line 1", "Enable closing line above sell trend", "General");

		_enableCloseBuyLine = Param(nameof(EnableCloseBuyLine), true)
			.SetDisplay("Close Line 2", "Enable closing line below buy trend", "General");

		_maxPositions = Param(nameof(MaxPositions), 1)
			.SetGreaterThanZero()
			.SetDisplay("Max Positions", "Maximum number of simultaneous lots", "Risk");

		_orderVolume = Param(nameof(OrderVolume), 0.10m)
			.SetGreaterThanZero()
			.SetDisplay("Volume", "Order volume per signal", "Risk");

		_lineOffsetSteps = Param(nameof(LineOffsetSteps), 60)
			.SetGreaterThanZero()
			.SetDisplay("Offset Steps", "Offset in price steps for close levels", "Execution");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles used for calculations", "General");
	}

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

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

		// Clear buffers that hold recent highs, lows, and times.
		_h0 = _h1 = _h2 = _h3 = _h4 = 0m;
		_l0 = _l1 = _l2 = _l3 = _l4 = 0m;
		_t0 = _t1 = _t2 = _t3 = _t4 = default;
		_bufferCount = 0;

		// Reset stored fractal points and active lines.
		_fractal0 = _fractal1 = _fractal2 = _fractal3 = _fractal4 = _fractal5 = null;
		_sellLine = null;
		_buyLine = null;
	}

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

		// Subscribe to candle data and process each completed candle.
		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(ProcessCandle).Start();
	}

	private void ProcessCandle(ICandleMessage candle)
	{
		// Work only with completed candles to match the MT5 expert behaviour.
		if (candle.State != CandleStates.Finished)
			return;

		// Update internal buffers and detect new fractal points.
		UpdateBuffers(candle);
		UpdateFractalLines();

		// Ensure trading is allowed before evaluating signals.
		if (!IsFormedAndOnlineAndAllowTrading())
			return;

		var offset = GetOffset();
		var shouldClose = false;

		// Check closing logic derived from the upper fractal trend line.
		if (EnableCloseSellLine && _sellLine is { } sellLine)
		{
			var closePrice = GetLinePrice(sellLine, candle.CloseTime) + offset;
			if (IsTouched(closePrice, candle))
				shouldClose = true;
		}

		// Check closing logic derived from the lower fractal trend line.
		if (EnableCloseBuyLine && _buyLine is { } buyLine)
		{
			var closePrice = GetLinePrice(buyLine, candle.CloseTime) - offset;
			if (IsTouched(closePrice, candle))
				shouldClose = true;
		}

		// Close any open position if price reached one of the close lines.
		if (shouldClose && Position != 0m)
		{
			if (Position > 0) SellMarket(); else BuyMarket();
			return;
		}

		// Entry logic for sell breakout.
		if (EnableSellLine && _sellLine is { } sellEntryLine)
		{
			var sellPrice = GetLinePrice(sellEntryLine, candle.CloseTime);
			if (IsTouched(sellPrice, candle))
			{
				if (Position > 0m)
				{
					// Flatten long positions before attempting to go short.
					SellMarket();
				}
				else if (CanIncreaseShort())
				{
					SellMarket(OrderVolume);
				}
			}
		}

		// Entry logic for buy breakout.
		if (EnableBuyLine && _buyLine is { } buyEntryLine)
		{
			var buyPrice = GetLinePrice(buyEntryLine, candle.CloseTime);
			if (IsTouched(buyPrice, candle))
			{
				if (Position < 0m)
				{
					// Flatten short positions before attempting to go long.
					BuyMarket();
				}
				else if (CanIncreaseLong())
				{
					BuyMarket(OrderVolume);
				}
			}
		}
	}

	private void UpdateBuffers(ICandleMessage candle)
	{
		// Shift buffers to keep the latest five candles for fractal detection.
		_h4 = _h3;
		_h3 = _h2;
		_h2 = _h1;
		_h1 = _h0;
		_h0 = candle.HighPrice;

		_l4 = _l3;
		_l3 = _l2;
		_l2 = _l1;
		_l1 = _l0;
		_l0 = candle.LowPrice;

		_t4 = _t3;
		_t3 = _t2;
		_t2 = _t1;
		_t1 = _t0;
		_t0 = candle.OpenTime;

		if (_bufferCount < 5)
		{
			_bufferCount++;
			return;
		}

		// Identify new fractal points once enough candles are available.
		if (IsUpFractal())
			RegisterFractal(new FractalPoint(FractalTypes.Up, _t2, _h2));

		if (IsDownFractal())
			RegisterFractal(new FractalPoint(FractalTypes.Down, _t2, _l2));
	}

	private void UpdateFractalLines()
	{
		// Build the sell line using the most recent up-down-up pattern.
		if (TryBuildLine(FractalTypes.Up, out var sellLine))
			_sellLine = sellLine;

		// Build the buy line using the most recent down-up-down pattern.
		if (TryBuildLine(FractalTypes.Down, out var buyLine))
			_buyLine = buyLine;
	}

	private bool IsUpFractal()
	{
		return _h2 >= _h3 && _h2 > _h4 && _h2 >= _h1 && _h2 > _h0;
	}

	private bool IsDownFractal()
	{
		return _l2 <= _l3 && _l2 < _l4 && _l2 <= _l1 && _l2 < _l0;
	}

	private void RegisterFractal(FractalPoint point)
	{
		// Skip duplicates that can appear on flat sequences.
		if (_fractal0 is { } latest && latest.Time == point.Time && latest.Type == point.Type)
			return;

		_fractal5 = _fractal4;
		_fractal4 = _fractal3;
		_fractal3 = _fractal2;
		_fractal2 = _fractal1;
		_fractal1 = _fractal0;
		_fractal0 = point;
	}

	private bool TryBuildLine(FractalTypes target, out FractalLine line)
	{
		line = default;
		FractalPoint? latest = null;
		FractalPoint? middle = null;
		FractalPoint? oldest = null;

		foreach (var item in EnumerateFractals())
		{
			if (item is not { } point)
				continue;

			if (latest is null)
			{
				if (point.Type == target)
					latest = point;
				continue;
			}

			if (middle is null)
			{
				if (point.Type != target)
					middle = point;
				continue;
			}

			if (point.Type == target)
			{
				oldest = point;
				break;
			}
		}

		if (latest is not { } latestPoint || middle is null || oldest is not { } oldestPoint)
			return false;

		if (latestPoint.Time == oldestPoint.Time)
			return false;

		line = new FractalLine(latestPoint, oldestPoint);
		return true;
	}

	private IEnumerable<FractalPoint?> EnumerateFractals()
	{
		yield return _fractal0;
		yield return _fractal1;
		yield return _fractal2;
		yield return _fractal3;
		yield return _fractal4;
		yield return _fractal5;
	}

	private bool CanIncreaseShort()
	{
		if (OrderVolume <= 0m || MaxPositions <= 0)
			return false;

		var lots = OrderVolume == 0m ? 0m : Math.Abs(Position) / OrderVolume;
		return lots < MaxPositions;
	}

	private bool CanIncreaseLong()
	{
		if (OrderVolume <= 0m || MaxPositions <= 0)
			return false;

		var lots = OrderVolume == 0m ? 0m : Math.Abs(Position) / OrderVolume;
		return lots < MaxPositions;
	}

	private decimal GetOffset()
	{
		var step = Security?.PriceStep ?? 1m;
		return step * LineOffsetSteps;
	}

	private static bool IsTouched(decimal price, ICandleMessage candle)
	{
		return price <= candle.HighPrice && price >= candle.LowPrice;
	}

	private static decimal GetLinePrice(FractalLine line, DateTimeOffset time)
	{
		return line.GetPrice(time);
	}
}