Cross Line Trader 策略
概述
该策略复刻了 MetaTrader 中的 “Cross Line Trader” 专家顾问,通过监控用户预先定义的合成线与价格的交互来下单。StockSharp 版本不再监听图表对象,而是从一个参数中读取所有线条描述,在启动时解析,并在每根收盘 K 线到来时持续监测。当新的 K 线开盘价穿越某条仍然有效的线时,策略会按照对应方向发送市价单,并将该线标记为不可再触发。
交易逻辑
- 通过 Candle Type 参数订阅指定的 K 线类型,只处理
Finished状态的 K 线以避免盘中噪声。 - 启动后根据 Line Definitions 参数创建合成线,每条线都维护自身的活动状态、已处理的条数以及几何信息。
- 对于 Trend 或 Horizontal 类型的线,算法比较上一根 K 线开盘价与当前开盘价相对于线条轨迹的位置:
- 当上一根开盘价位于线下方,而当前开盘价突破至线上方时触发做多。
- 当上一根开盘价位于线上方,而当前开盘价跌破至线下方时触发做空。
- Vertical 线等同于定时触发器。在达到预设的条数后,策略会在当根 K 线的开盘价立刻开仓。
- 交易方向由 Direction Mode 决定:
FromLabel会把线条标签与 Buy Label / Sell Label 比较。ForceBuy与ForceSell忽略标签,将所有线统一视为一个方向。
- 每次成功触发都会按照 Trade Volume 设定的数量下市价单,写入日志,并将该线设为非活动状态。
- 若设置了止损或止盈距离,策略会在每根新 K 线上根据最新持仓价格以及当根的最高 / 最低价来判断是否需要平仓。
线条定义格式
Line Definitions 字符串使用分号分隔多条线,每条线需遵循以下格式:
Name|Type|Label|BasePrice|SlopePerBar|Length|Ray
- Name:日志中显示的名称,可任意填写但不能包含分号。
- Type:
Horizontal、Trend或Vertical(大小写不敏感)。 - Label:自由文本,在
FromLabel模式下与 Buy Label/Sell Label 匹配。 - BasePrice:第一根处理的 K 线对应的初始价格,所有非垂直线都必须提供(使用十进制,采用不变文化写法)。
- SlopePerBar:趋势线的每根 K 线价格增量;水平线填
0。 - Length:含义取决于线条类型:
- 对于没有 Ray 的趋势线或水平线,表示右端点距离起点的 K 线数量,超过后该线自动失效。
- 若 Ray 为
true,该值被忽略,线将无限延伸。 - 对于垂直线,表示等待多少根 K 线后触发,最小值为
1。
- Ray:
true表示向右无限延伸,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:决定如何确定进场方向,可选
FromLabel、ForceBuy、ForceSell。 - 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 一样只关注开盘价,盘中触碰不会触发交易。
使用步骤
- 配置交易标的及所需的 K 线类型。
- 根据需求填写 Line Definitions,定义所有希望交易的线条。
- 选择适当的 Direction Mode,决定是否使用标签区分方向。
- 可选地设置止损和止盈距离,实现自动退出。
- 启动策略并查看日志,每次触发都会记录线条名称、方向及触发价格。
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;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
class cross_line_trader_strategy(Strategy):
DIR_FROM_LABEL = 0
DIR_FORCE_BUY = 1
DIR_FORCE_SELL = 2
LINE_HORIZONTAL = 0
LINE_TREND = 1
LINE_VERTICAL = 2
def __init__(self):
super(cross_line_trader_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5)))
self._trade_volume = self.Param("TradeVolume", 1.0)
self._direction_mode = self.Param("DirectionMode", self.DIR_FROM_LABEL)
self._buy_label = self.Param("BuyLabel", "Buy")
self._sell_label = self.Param("SellLabel", "Sell")
self._line_definitions = self.Param("LineDefinitions",
"TrendLine|Trend|Buy|64000|50|20|false;HorizontalSell|Horizontal|Sell|68000|0|0|true;HorizontalBuy|Horizontal|Buy|62000|0|0|true")
self._stop_loss_offset = self.Param("StopLossOffset", 0.0)
self._take_profit_offset = self.Param("TakeProfitOffset", 0.0)
self._lines = []
self._previous_open = None
self._entry_price = 0.0
@property
def CandleType(self):
return self._candle_type.Value
@property
def TradeVolume(self):
return self._trade_volume.Value
@property
def DirectionMode(self):
return self._direction_mode.Value
@property
def BuyLabel(self):
return self._buy_label.Value
@property
def SellLabel(self):
return self._sell_label.Value
@property
def LineDefinitions(self):
return self._line_definitions.Value
@property
def StopLossOffset(self):
return self._stop_loss_offset.Value
@property
def TakeProfitOffset(self):
return self._take_profit_offset.Value
def OnStarted2(self, time):
super(cross_line_trader_strategy, self).OnStarted2(time)
self._lines = self._parse_line_definitions(self.LineDefinitions)
self._previous_open = None
self._entry_price = 0.0
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self._process_candle).Start()
def OnOwnTradeReceived(self, trade):
super(cross_line_trader_strategy, self).OnOwnTradeReceived(trade)
if trade is None or trade.Trade is None:
return
pos = float(self.Position)
if pos == 0:
self._entry_price = 0.0
return
self._entry_price = float(trade.Trade.Price)
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
if float(self.TradeVolume) <= 0:
return
current_open = float(candle.OpenPrice)
for line in self._lines:
if not line["is_active"]:
continue
previous_index = line["steps_processed"]
current_index = previous_index + 1
if line["type"] == self.LINE_VERTICAL:
if current_index >= max(1, line["length"]):
direction = self._resolve_direction(line)
if direction is not None and self._try_open_position(direction, candle):
line["is_active"] = False
line["steps_processed"] = current_index
continue
if not line["ray"] and line["length"] > 0 and previous_index >= line["length"]:
line["is_active"] = False
continue
if self._previous_open is None:
line["steps_processed"] = current_index
continue
prev_line_price = self._get_line_price(line, previous_index)
curr_line_price = self._get_line_price(line, current_index)
dir_for_line = self._resolve_direction(line)
if dir_for_line is None:
line["steps_processed"] = current_index
continue
if line["type"] == self.LINE_HORIZONTAL:
cross_up = self._previous_open <= prev_line_price and current_open > prev_line_price
cross_down = self._previous_open >= prev_line_price and current_open < prev_line_price
else:
cross_up = self._previous_open <= prev_line_price and current_open > curr_line_price
cross_down = self._previous_open >= prev_line_price and current_open < curr_line_price
pos = float(self.Position)
if cross_up and dir_for_line == 1 and pos <= 0:
if self._try_open_position(1, candle):
line["is_active"] = False
elif cross_down and dir_for_line == -1 and pos >= 0:
if self._try_open_position(-1, candle):
line["is_active"] = False
line["steps_processed"] = current_index
if not line["ray"] and line["length"] > 0 and current_index >= line["length"]:
line["is_active"] = False
self._manage_protective_exits(candle)
self._previous_open = current_open
def _resolve_direction(self, line):
mode = self.DirectionMode
if mode == self.DIR_FORCE_BUY:
return 1
elif mode == self.DIR_FORCE_SELL:
return -1
elif mode == self.DIR_FROM_LABEL:
buy_label = self.BuyLabel
sell_label = self.SellLabel
label = line["label"]
if buy_label and label.lower() == buy_label.lower():
return 1
if sell_label and label.lower() == sell_label.lower():
return -1
return None
def _try_open_position(self, direction, candle):
pos = float(self.Position)
if direction == 1:
if pos > 0:
return False
self.BuyMarket(float(self.TradeVolume))
self._entry_price = float(candle.OpenPrice)
else:
if pos < 0:
return False
self.SellMarket(float(self.TradeVolume))
self._entry_price = float(candle.OpenPrice)
return True
def _manage_protective_exits(self, candle):
pos = float(self.Position)
if pos > 0:
volume = abs(pos)
if float(self.StopLossOffset) > 0 and float(candle.LowPrice) <= self._entry_price - float(self.StopLossOffset):
self.SellMarket(volume)
return
if float(self.TakeProfitOffset) > 0 and float(candle.HighPrice) >= self._entry_price + float(self.TakeProfitOffset):
self.SellMarket(volume)
elif pos < 0:
volume = abs(pos)
if float(self.StopLossOffset) > 0 and float(candle.HighPrice) >= self._entry_price + float(self.StopLossOffset):
self.BuyMarket(volume)
return
if float(self.TakeProfitOffset) > 0 and float(candle.LowPrice) <= self._entry_price - float(self.TakeProfitOffset):
self.BuyMarket(volume)
def _get_line_price(self, line, index):
if line["type"] == self.LINE_VERTICAL:
return 0.0
clamped = max(0, index)
if not line["ray"] and line["length"] > 0:
clamped = min(clamped, line["length"])
return line["base_price"] + line["slope"] * clamped
def _parse_line_definitions(self, raw):
result = []
if raw is None or raw.strip() == "":
return result
entries = raw.replace("\n", ";").replace("\r", ";").split(";")
for entry in entries:
entry = entry.strip()
if not entry:
continue
parts = entry.split("|")
if len(parts) < 7:
continue
name = parts[0].strip()
type_text = parts[1].strip().lower()
label = parts[2].strip()
try:
base_price = float(parts[3].strip())
except:
continue
try:
slope = float(parts[4].strip())
except:
slope = 0.0
try:
length = int(parts[5].strip())
except:
length = 0
ray_text = parts[6].strip().lower()
ray = ray_text == "true"
if type_text == "horizontal":
line_type = self.LINE_HORIZONTAL
elif type_text == "trend":
line_type = self.LINE_TREND
elif type_text == "vertical":
line_type = self.LINE_VERTICAL
else:
continue
if line_type == self.LINE_VERTICAL and length <= 0:
length = 1
result.append({
"name": name if name else type_text,
"label": label,
"type": line_type,
"base_price": base_price,
"slope": slope,
"length": max(0, length),
"ray": ray,
"is_active": True,
"steps_processed": 0
})
return result
def OnReseted(self):
super(cross_line_trader_strategy, self).OnReseted()
self._lines = []
self._previous_open = None
self._entry_price = 0.0
def CreateClone(self):
return cross_line_trader_strategy()