Открыть на GitHub

Стратегия Cross Line Trader

Обзор

Стратегия повторяет логику советника MetaTrader «Cross Line Trader»: она реагирует на пересечения цены с набором заранее описанных линий. В версии для StockSharp линии не рисуются вручную на графике, а задаются одной строкой параметра. При запуске строка разбирается, после чего стратегия отслеживает только завершённые свечи. Как только новая свеча открывается по другую сторону активной линии, отправляется рыночный ордер в соответствующем направлении, а сама линия выключается, чтобы не сработать повторно.

Торговая логика

  1. Подписка идёт на тип свечей из параметра Candle Type; обрабатываются только свечи в состоянии Finished, что исключает внутридневной шум.
  2. Линии создаются из параметра Line Definitions, каждая хранит собственное состояние: активность, счётчик обработанных баров и геометрию.
  3. Для линий типа Trend и Horizontal стратегия сравнивает открытия двух соседних свечей относительно траектории линии:
    • Лонг активируется, если предыдущее открытие было ниже линии, а текущее — выше.
    • Шорт активируется, если предыдущее открытие было выше линии, а текущее — ниже.
  4. Vertical-линии работают как таймеры: по истечении заданного числа баров позиция открывается немедленно по текущему открытию свечи.
  5. Направление сделки определяется параметром Direction Mode:
    • FromLabel сопоставляет подпись линии со значениями Buy Label и Sell Label.
    • ForceBuy и ForceSell принудительно используют только одну сторону независимо от подписи.
  6. После удачного срабатывания подаётся рыночный ордер объёмом из Trade Volume, событие записывается в лог, а линия деактивируется.
  7. Параметры Stop Loss Offset и Take Profit Offset задают защитные расстояния: на каждой новой свече стратегия проверяет минимум/максимум относительно последней цены входа и при необходимости закрывает позицию.

Формат описания линий

Параметр Line Definitions состоит из записей, разделённых точкой с запятой. Каждая запись имеет вид:

Name|Type|Label|BasePrice|SlopePerBar|Length|Ray
  • Name — произвольное имя для логов, не должно содержать точек с запятой.
  • TypeHorizontal, Trend или Vertical (без учёта регистра).
  • Label — текст для сопоставления в режиме FromLabel.
  • BasePrice — базовая цена линии для первой обрабатываемой свечи (десятичное число в invariant culture).
  • SlopePerBar — изменение цены на один бар для трендовой линии; для горизонтальной укажите 0.
  • Length — интерпретация зависит от типа:
    • Для трендовых и горизонтальных линий без луча задаёт, насколько далеко вправо расположен конечный бар; после достижения значения линия отключается.
    • Для лучей (Ray = true) параметр игнорируется — линия активна бесконечно.
    • Для вертикальных линий определяет, через сколько баров сработает триггер (минимум 1).
  • Raytrue оставляет линию активной бесконечно вправо, false ограничивает область действия значением Length.

Пример строки:

TrendLine|Trend|Buy|1.1000|0.0005|8|false;HorizontalSell|Horizontal|Sell|1.1050|0|0|true;VerticalImpulse|Vertical|Buy|0|0|1|false

Здесь задана восходящая линия для покупок, горизонтальный уровень для продаж (без срока действия) и вертикальный триггер на следующую свечу.

Параметры

  • Candle Type — тип свечей, используемый в расчётах; по умолчанию таймфрейм 1 минута.
  • Trade Volume — объём рыночных заявок при открытии позиции (должен быть больше нуля).
  • Direction Mode — способ выбора направления (FromLabel, ForceBuy, ForceSell).
  • Buy Label / Sell Label — подписи, определяющие сделки при режиме FromLabel.
  • Line Definitions — строка с описанием всех линий (см. формат выше).
  • Stop Loss Offset — защитное расстояние в ценовых единицах; значение 0 отключает контроль.
  • Take Profit Offset — расстояние до цели по прибыли; 0 отключает контроль.

Управление рисками

Отдельные стоп- и тейк-приказы не выставляются. Вместо этого стратегия на каждой закрывшейся свече проверяет:

  • Для лонга: минимум <= EntryPrice - StopLossOffset или максимум >= EntryPrice + TakeProfitOffset — позиция закрывается продажей по рынку.
  • Для шорта: максимум >= EntryPrice + StopLossOffset или минимум <= EntryPrice - TakeProfitOffset — позиция закрывается покупкой по рынку.

Если оба смещения равны нулю, позиция закрывается только встречным сигналом или вручную.

Особенности реализации

  • Все комментарии в исходнике написаны на английском языке согласно требованиям проекта.
  • Некорректные записи в строке линий пропускаются без ошибок, поэтому рекомендуется тщательно проверять формат.
  • Перезапуск стратегии сбрасывает внутренние счётчики, и ожидание по линиям начинается заново с первой новой свечи.
  • Алгоритм ориентируется исключительно на цены открытия, как и оригинальный эксперт, поэтому внутрисвечные касания не учитываются.

Порядок использования

  1. Выберите торговый инструмент и подходящий тип свечей.
  2. Заполните Line Definitions, описав все нужные уровни и трендовые линии.
  3. Настройте Direction Mode и, при необходимости, подписи для покупок и продаж.
  4. Укажите защитные расстояния, если требуется автоматический выход.
  5. Запустите стратегию и следите за журналом: каждая сработавшая линия будет отмечена направлением и ценой срабатывания.
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;

using System.Globalization;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Strategy that opens positions when price crosses predefined synthetic lines.
/// It replicates the idea of the original MQL Cross Line Trader by checking finished candles.
/// </summary>
public class CrossLineTraderStrategy : Strategy
{
	public enum LineDirectionModes
	{
		FromLabel,
		ForceBuy,
		ForceSell,
	}

	private enum LineTypes
	{
		Horizontal,
		Trend,
		Vertical,
	}

	private enum TradeDirections
	{
		Buy,
		Sell,
	}

	private sealed class LineState
	{
		public LineState(string name, string label, LineTypes type, decimal basePrice, decimal slopePerBar, int length, bool ray)
		{
			Name = name.IsEmptyOrWhiteSpace() ? type.ToString() : name;
			Label = label ?? string.Empty;
			Type = type;
			BasePrice = basePrice;
			SlopePerBar = slopePerBar;
			Length = Math.Max(0, length);
			Ray = ray;
			IsActive = true;
			StepsProcessed = 0;
		}

		public string Name { get; }

		public string Label { get; }

		public LineTypes Type { get; }

		public decimal BasePrice { get; }

		public decimal SlopePerBar { get; }

		public int Length { get; }

		public bool Ray { get; }

		public bool IsActive { get; set; }

		public int StepsProcessed { get; set; }

		public decimal GetPrice(int index)
		{
			if (Type == LineTypes.Vertical)
				return 0m;

			var clampedIndex = Math.Max(0, index);

			if (!Ray && Length > 0)
				clampedIndex = Math.Min(clampedIndex, Length);

			return BasePrice + SlopePerBar * clampedIndex;
		}
	}

	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _tradeVolume;
	private readonly StrategyParam<LineDirectionModes> _directionMode;
	private readonly StrategyParam<string> _buyLabel;
	private readonly StrategyParam<string> _sellLabel;
	private readonly StrategyParam<string> _lineDefinitions;
	private readonly StrategyParam<decimal> _stopLossOffset;
	private readonly StrategyParam<decimal> _takeProfitOffset;

	private List<LineState> _lines = new();
	private decimal? _previousOpen;
	private decimal _entryPrice;

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

	/// <summary>
	/// Volume sent with market orders.
	/// </summary>
	public decimal TradeVolume
	{
		get => _tradeVolume.Value;
		set => _tradeVolume.Value = value;
	}

	/// <summary>
	/// Defines how the strategy resolves trade direction for every line.
	/// </summary>
	public LineDirectionModes DirectionMode
	{
		get => _directionMode.Value;
		set => _directionMode.Value = value;
	}

	/// <summary>
	/// Text label that identifies buy lines when DirectionMode is FromLabel.
	/// </summary>
	public string BuyLabel
	{
		get => _buyLabel.Value;
		set => _buyLabel.Value = value;
	}

	/// <summary>
	/// Text label that identifies sell lines when DirectionMode is FromLabel.
	/// </summary>
	public string SellLabel
	{
		get => _sellLabel.Value;
		set => _sellLabel.Value = value;
	}

	/// <summary>
	/// Raw definition of synthetic lines. Each line uses the format:
	/// Name|Type|Label|BasePrice|SlopePerBar|Length|Ray
	/// </summary>
	public string LineDefinitions
	{
		get => _lineDefinitions.Value;
		set => _lineDefinitions.Value = value;
	}

	/// <summary>
	/// Protective stop distance in price units.
	/// </summary>
	public decimal StopLossOffset
	{
		get => _stopLossOffset.Value;
		set => _stopLossOffset.Value = value;
	}

	/// <summary>
	/// Protective take profit distance in price units.
	/// </summary>
	public decimal TakeProfitOffset
	{
		get => _takeProfitOffset.Value;
		set => _takeProfitOffset.Value = value;
	}

	/// <summary>
	/// Constructor that configures strategy parameters.
	/// </summary>
	public CrossLineTraderStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles used for intersections", "Data");

		_tradeVolume = Param(nameof(TradeVolume), 1m)
			.SetDisplay("Trade Volume", "Order volume used for entries", "Trading")
			.SetGreaterThanZero();

		_directionMode = Param(nameof(DirectionMode), LineDirectionModes.FromLabel)
			.SetDisplay("Direction Mode", "How to pick trade direction for a line", "Trading");

		_buyLabel = Param(nameof(BuyLabel), "Buy")
			.SetDisplay("Buy Label", "Text that marks buy lines when mode uses labels", "Trading");

		_sellLabel = Param(nameof(SellLabel), "Sell")
			.SetDisplay("Sell Label", "Text that marks sell lines when mode uses labels", "Trading");

		_lineDefinitions = Param(nameof(LineDefinitions),
			"TrendLine|Trend|Buy|64000|50|20|false;HorizontalSell|Horizontal|Sell|68000|0|0|true;HorizontalBuy|Horizontal|Buy|62000|0|0|true")
			.SetDisplay("Line Definitions", "Encoded collection of synthetic lines", "Lines")
			;

		_stopLossOffset = Param(nameof(StopLossOffset), 0m)
			.SetDisplay("Stop Loss Offset", "Price distance for protective exit", "Risk")
			.SetNotNegative();

		_takeProfitOffset = Param(nameof(TakeProfitOffset), 0m)
			.SetDisplay("Take Profit Offset", "Price distance for profit taking", "Risk")
			.SetNotNegative();
	}

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

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

		_lines = new List<LineState>();
		_previousOpen = null;
		_entryPrice = 0m;
	}

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

		_lines = ParseLineDefinitions(LineDefinitions);
		_previousOpen = null;
		_entryPrice = 0m;

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(ProcessCandle)
			.Start();
	}

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

		if (TradeVolume <= 0)
			return;

		var currentOpen = candle.OpenPrice;

		foreach (var line in _lines)
		{
			if (!line.IsActive)
				continue;

			var previousIndex = line.StepsProcessed;
			var currentIndex = previousIndex + 1;

			if (line.Type == LineTypes.Vertical)
			{
				if (currentIndex >= Math.Max(1, line.Length))
				{
					var direction = ResolveDirection(line);

					if (direction != null && TryOpenPosition(direction.Value, line, candle))
						line.IsActive = false;
				}

				line.StepsProcessed = currentIndex;
				continue;
			}

			if (!line.Ray && line.Length > 0 && previousIndex >= line.Length)
			{
				line.IsActive = false;
				continue;
			}

			if (_previousOpen is null)
			{
				line.StepsProcessed = currentIndex;
				continue;
			}

			var previousLinePrice = line.GetPrice(previousIndex);
			var currentLinePrice = line.GetPrice(currentIndex);
			var directionForLine = ResolveDirection(line);

			if (directionForLine is null)
			{
				line.StepsProcessed = currentIndex;
				continue;
			}

			var crossUp = line.Type == LineTypes.Horizontal
				? _previousOpen.Value <= previousLinePrice && currentOpen > previousLinePrice
				: _previousOpen.Value <= previousLinePrice && currentOpen > currentLinePrice;

			var crossDown = line.Type == LineTypes.Horizontal
				? _previousOpen.Value >= previousLinePrice && currentOpen < previousLinePrice
				: _previousOpen.Value >= previousLinePrice && currentOpen < currentLinePrice;

			if (crossUp && directionForLine == TradeDirections.Buy && Position <= 0)
			{
				if (TryOpenPosition(TradeDirections.Buy, line, candle))
					line.IsActive = false;
			}
			else if (crossDown && directionForLine == TradeDirections.Sell && Position >= 0)
			{
				if (TryOpenPosition(TradeDirections.Sell, line, candle))
					line.IsActive = false;
			}

			line.StepsProcessed = currentIndex;

			if (!line.Ray && line.Length > 0 && currentIndex >= line.Length)
				line.IsActive = false;
		}

		ManageProtectiveExits(candle);

		_previousOpen = currentOpen;
	}

	private TradeDirections? ResolveDirection(LineState line)
	{
		switch (DirectionMode)
		{
			case LineDirectionModes.ForceBuy:
				return TradeDirections.Buy;
			case LineDirectionModes.ForceSell:
				return TradeDirections.Sell;
			case LineDirectionModes.FromLabel:
				if (!BuyLabel.IsEmptyOrWhiteSpace() &&
					line.Label.EqualsIgnoreCase(BuyLabel))
					return TradeDirections.Buy;

				if (!SellLabel.IsEmptyOrWhiteSpace() &&
					line.Label.EqualsIgnoreCase(SellLabel))
					return TradeDirections.Sell;
				break;
		}

		return null;
	}

	private bool TryOpenPosition(TradeDirections direction, LineState line, ICandleMessage candle)
	{
		if (direction == TradeDirections.Buy)
		{
			if (Position > 0)
				return false;

			BuyMarket(TradeVolume);
			_entryPrice = candle.OpenPrice;
			// BUY triggered
		}
		else
		{
			if (Position < 0)
				return false;

			SellMarket(TradeVolume);
			_entryPrice = candle.OpenPrice;
			// SELL triggered
		}

		return true;
	}

	private void ManageProtectiveExits(ICandleMessage candle)
	{
		if (Position > 0)
		{
			var volume = Math.Abs(Position);

			if (StopLossOffset > 0m && candle.LowPrice <= _entryPrice - StopLossOffset)
			{
				SellMarket(volume);
				return;
			}

			if (TakeProfitOffset > 0m && candle.HighPrice >= _entryPrice + TakeProfitOffset)
			{
				SellMarket(volume);
			}
		}
		else if (Position < 0)
		{
			var volume = Math.Abs(Position);

			if (StopLossOffset > 0m && candle.HighPrice >= _entryPrice + StopLossOffset)
			{
				BuyMarket(volume);
				return;
			}

			if (TakeProfitOffset > 0m && candle.LowPrice <= _entryPrice - TakeProfitOffset)
			{
				BuyMarket(volume);
			}
		}
	}

	private List<LineState> ParseLineDefinitions(string raw)
	{
		var result = new List<LineState>();

		if (raw.IsEmptyOrWhiteSpace())
			return result;

		var entries = raw.Split(new[] { '\n', '\r', ';' }, StringSplitOptions.RemoveEmptyEntries);

		foreach (var entry in entries)
		{
			var parts = entry.Split('|');

			if (parts.Length < 7)
				continue;

			var name = parts[0].Trim();
			var typeText = parts[1].Trim();
			var label = parts[2].Trim();
			var basePriceText = parts[3].Trim();
			var slopeText = parts[4].Trim();
			var lengthText = parts[5].Trim();
			var rayText = parts[6].Trim();

			if (!Enum.TryParse<LineTypes>(typeText, true, out var type))
				continue;

			if (!decimal.TryParse(basePriceText, NumberStyles.Number, CultureInfo.InvariantCulture, out var basePrice))
				continue;

			if (!decimal.TryParse(slopeText, NumberStyles.Number, CultureInfo.InvariantCulture, out var slope))
				slope = 0m;

			if (!int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var length))
				length = 0;

			if (!bool.TryParse(rayText, out var ray))
				ray = false;

			if (type == LineTypes.Vertical && length <= 0)
				length = 1;

			result.Add(new LineState(name, label, type, basePrice, slope, length, ray));
		}

		return result;
	}

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

		if (Position == 0)
		{
			_entryPrice = 0m;
			return;
		}

		_entryPrice = trade.Trade.Price;
	}
}