在 GitHub 上查看

Cross Line Trader 策略

概述

该策略复刻了 MetaTrader 中的 “Cross Line Trader” 专家顾问,通过监控用户预先定义的合成线与价格的交互来下单。StockSharp 版本不再监听图表对象,而是从一个参数中读取所有线条描述,在启动时解析,并在每根收盘 K 线到来时持续监测。当新的 K 线开盘价穿越某条仍然有效的线时,策略会按照对应方向发送市价单,并将该线标记为不可再触发。

交易逻辑

  1. 通过 Candle Type 参数订阅指定的 K 线类型,只处理 Finished 状态的 K 线以避免盘中噪声。
  2. 启动后根据 Line Definitions 参数创建合成线,每条线都维护自身的活动状态、已处理的条数以及几何信息。
  3. 对于 TrendHorizontal 类型的线,算法比较上一根 K 线开盘价与当前开盘价相对于线条轨迹的位置:
    • 当上一根开盘价位于线下方,而当前开盘价突破至线上方时触发做多。
    • 当上一根开盘价位于线上方,而当前开盘价跌破至线下方时触发做空。
  4. Vertical 线等同于定时触发器。在达到预设的条数后,策略会在当根 K 线的开盘价立刻开仓。
  5. 交易方向由 Direction Mode 决定:
    • FromLabel 会把线条标签与 Buy Label / Sell Label 比较。
    • ForceBuyForceSell 忽略标签,将所有线统一视为一个方向。
  6. 每次成功触发都会按照 Trade Volume 设定的数量下市价单,写入日志,并将该线设为非活动状态。
  7. 若设置了止损或止盈距离,策略会在每根新 K 线上根据最新持仓价格以及当根的最高 / 最低价来判断是否需要平仓。

线条定义格式

Line Definitions 字符串使用分号分隔多条线,每条线需遵循以下格式:

Name|Type|Label|BasePrice|SlopePerBar|Length|Ray
  • Name:日志中显示的名称,可任意填写但不能包含分号。
  • TypeHorizontalTrendVertical(大小写不敏感)。
  • Label:自由文本,在 FromLabel 模式下与 Buy Label/Sell Label 匹配。
  • BasePrice:第一根处理的 K 线对应的初始价格,所有非垂直线都必须提供(使用十进制,采用不变文化写法)。
  • SlopePerBar:趋势线的每根 K 线价格增量;水平线填 0
  • Length:含义取决于线条类型:
    • 对于没有 Ray 的趋势线或水平线,表示右端点距离起点的 K 线数量,超过后该线自动失效。
    • 若 Ray 为 true,该值被忽略,线将无限延伸。
    • 对于垂直线,表示等待多少根 K 线后触发,最小值为 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

该示例包含一条上升趋势买线、一条永久有效的水平卖线以及一条下一根 K 线立即触发的垂直线。

参数说明

  • Candle Type:用于计算的行情数据类型,默认 1 分钟 K 线。
  • Trade Volume:开仓市价单的数量,必须为正值。
  • Direction Mode:决定如何确定进场方向,可选 FromLabelForceBuyForceSell
  • Buy Label / Sell Label:在 FromLabel 模式下用来识别买卖线条的标签文本。
  • Line Definitions:描述所有合成线的原始字符串(格式如上)。
  • Stop Loss Offset:以价格单位表示的止损距离,设置为 0 表示禁用。
  • Take Profit Offset:以价格单位表示的止盈距离,设置为 0 表示禁用。

风险控制

策略不会单独挂出止损或止盈委托,而是在每根收盘 K 线上检查:

  • 多头持仓:若最低价低于 EntryPrice - StopLossOffset,或最高价超过 EntryPrice + TakeProfitOffset,则市价平仓。
  • 空头持仓:若最高价高于 EntryPrice + StopLossOffset,或最低价低于 EntryPrice - TakeProfitOffset,则市价平仓。

当两项距离均为 0 时,仓位仅会由反向信号或人工操作关闭。

实现细节

  • 代码中的注释均采用英文,符合项目规范。
  • 格式错误的线条定义会被忽略,不会报错,请确保文本填写正确。
  • 重启策略会重置内部状态,线条计数与触发计时从第一根新 K 线重新开始。
  • 与原始 EA 一样只关注开盘价,盘中触碰不会触发交易。

使用步骤

  1. 配置交易标的及所需的 K 线类型。
  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;
	}
}