在 GitHub 上查看

箭头与曲线策略

概述

该策略是 MetaTrader 5 "Arrows and Curves" 专家顾问的 C# 版本。策略在 StockSharp 高阶 API 中复现了原始指标驱动的交易逻辑,只针对单一标的进行操作。每次只有一笔持仓处于激活状态,新的信号会在没有仓位时开仓,或在已有仓位时执行平仓。

策略逻辑

  • 通过 SubscribeCandles 订阅所选时间框的K线,并且只处理已完成的K线,这与原始 EA 仅在新柱出现时运作的方式一致。
  • 在策略内部重建 Arrows and Curves 通道:算法在 SSP 回溯窗口内(向后偏移 Relay 根柱)查找最高价和最低价,再根据 Channel %Channel Stop % 计算出外层与内层带状线。
  • 指标状态变量 uptrenduptrend2 的更新顺序完全按照 MQL 代码执行。上一根K线出现 Sell 箭头时,策略为多头做好准备;出现 Buy 箭头时,则为空头做好准备。这与原始 EA 在下一根柱读取索引为1的缓冲区值的逻辑一致。
  • 当没有持仓时,使用上一根K线保存的信号开市价单,并且方向与箭头相反(Sell 箭头→买入,Buy 箭头→卖出)。
  • 当已经有持仓时,若出现反向信号则先行平仓,但不会立刻反向开仓——这与 MT5 源码一致:先退出,再在下一根K线上重新评估入场。

风险管理

  • 止损与止盈以“点”作为单位,并通过 PriceStep 转换为绝对价格距离。如果品种报价有3位或5位小数,则会将最小价位变动乘以10,以复刻 EA 中的点值调整方法。
  • 拖尾止损遵循 EA 的实现:当浮动盈利超过 Trailing Stop + Trailing Step 时,保护性止损会按设定的距离跟随,并遵守最小移动步长。
  • 每根完成的K线都会使用当根的最高价/最低价来近似检测止损或止盈是否触发,以模拟盘中触发效果。
  • Volume 参数提供固定下单数量;当其设为0时,策略会按照 Risk % 将账户价值的一定比例暴露在配置的止损距离之内,从而得到动态下单数量。

参数

  • Volume:固定下单数量;为0时启用风险百分比头寸管理。
  • Risk %:在启用动态仓位时用于计算风险的账户百分比。
  • Stop Loss (pips):以点数表示的止损距离。
  • Take Profit (pips):以点数表示的止盈距离。
  • Trailing Stop (pips):拖尾止损距离,为0时禁用。
  • Trailing Step (pips):拖尾重新移动之前所需的附加点数。
  • SSP:计算通道范围的回溯K线数量。
  • Channel %:外层带状线百分比,完全对应原始 MT5 设置。
  • Channel Stop %:控制内部状态翻转的内层带状线百分比。
  • Relay:通道计算中使用的偏移量。
  • Candle Type:参与计算的时间框或K线类型。

实现说明

  • 策略仅保留计算指标所需的最小历史数据(SSP + Relay + 5 根K线)。
  • 代码中的注释和辅助函数全部使用英文,以满足仓库约定。
  • 与 MT5 不同,止损和止盈在此实现中通过K线的高低价模拟,因此盘中触发可能与原始 EA 略有差异;除这一点外,其余决策逻辑与原始脚本保持一致。
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>
/// Port of the MT5 Arrows and Curves expert advisor using StockSharp high level API.
/// </summary>
public class ArrowsAndCurvesStrategy : Strategy
{
	private readonly StrategyParam<decimal> _volume;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<int> _trailingStopPips;
	private readonly StrategyParam<int> _trailingStepPips;
	private readonly StrategyParam<int> _sspPeriod;
	private readonly StrategyParam<int> _channelPercent;
	private readonly StrategyParam<int> _channelStopPercent;
	private readonly StrategyParam<int> _relayShift;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<decimal> _highSeries = new();
	private readonly List<decimal> _lowSeries = new();
	private readonly List<decimal> _closeSeries = new();

	private bool _uptrend;
	private bool _uptrend2;
	private bool _previousSellArrow;
	private bool _previousBuyArrow;

	private decimal? _entryPrice;
	private decimal? _stopPrice;
	private decimal? _takePrice;

	public decimal VolumeValue { get => _volume.Value; set => _volume.Value = value; }
	public decimal RiskPercent { get => _riskPercent.Value; set => _riskPercent.Value = value; }
	public int StopLossPips { get => _stopLossPips.Value; set => _stopLossPips.Value = value; }
	public int TakeProfitPips { get => _takeProfitPips.Value; set => _takeProfitPips.Value = value; }
	public int TrailingStopPips { get => _trailingStopPips.Value; set => _trailingStopPips.Value = value; }
	public int TrailingStepPips { get => _trailingStepPips.Value; set => _trailingStepPips.Value = value; }
	public int SspPeriod { get => _sspPeriod.Value; set => _sspPeriod.Value = value; }
	public int ChannelPercent { get => _channelPercent.Value; set => _channelPercent.Value = value; }
	public int ChannelStopPercent { get => _channelStopPercent.Value; set => _channelStopPercent.Value = value; }
	public int RelayShift { get => _relayShift.Value; set => _relayShift.Value = value; }
	public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }

	public ArrowsAndCurvesStrategy()
	{
		_volume = Param(nameof(VolumeValue), 1m)
		.SetNotNegative()
		.SetDisplay("Volume", "Order volume", "Trading");

		_riskPercent = Param(nameof(RiskPercent), 5m)
		.SetNotNegative()
		.SetDisplay("Risk %", "Risk percent for dynamic sizing when volume is zero", "Trading");

		_stopLossPips = Param(nameof(StopLossPips), 50)
		.SetNotNegative()
		.SetDisplay("Stop Loss (pips)", "Stop loss distance in pips", "Risk");

		_takeProfitPips = Param(nameof(TakeProfitPips), 50)
		.SetNotNegative()
		.SetDisplay("Take Profit (pips)", "Take profit distance in pips", "Risk");

		_trailingStopPips = Param(nameof(TrailingStopPips), 0)
		.SetNotNegative()
		.SetDisplay("Trailing Stop (pips)", "Trailing stop distance", "Risk");

		_trailingStepPips = Param(nameof(TrailingStepPips), 5)
		.SetNotNegative()
		.SetDisplay("Trailing Step (pips)", "Minimum movement before trailing updates", "Risk");

		_sspPeriod = Param(nameof(SspPeriod), 20)
		.SetGreaterThanZero()
		.SetDisplay("SSP", "Lookback period of the custom channel", "Indicator");

		_channelPercent = Param(nameof(ChannelPercent), 0)
		.SetNotNegative()
		.SetDisplay("Channel %", "Outer channel percentage", "Indicator");

		_channelStopPercent = Param(nameof(ChannelStopPercent), 30)
		.SetNotNegative()
		.SetDisplay("Channel Stop %", "Inner channel percentage", "Indicator");

		_relayShift = Param(nameof(RelayShift), 10)
		.SetNotNegative()
		.SetDisplay("Relay", "Shift used by the indicator", "Indicator");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
		.SetDisplay("Candle Type", "Candles used for processing", "General");
	}

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

	protected override void OnReseted()
	{
		base.OnReseted();
		_highSeries.Clear();
		_lowSeries.Clear();
		_closeSeries.Clear();
		_uptrend = false;
		_uptrend2 = false;
		_previousSellArrow = false;
		_previousBuyArrow = false;
		_entryPrice = null;
		_stopPrice = null;
		_takePrice = null;
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

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

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

		AddCandle(candle);

		var shouldOpenBuy = _previousSellArrow;
		var shouldOpenSell = _previousBuyArrow;

		if (Position == 0)
		{
			if (shouldOpenBuy)
			OpenLong(candle);
			else if (shouldOpenSell)
			OpenShort(candle);
		}
		else
		{
			if (Position > 0 && shouldOpenSell)
			{
				CloseAndReset();
			}
			else if (Position < 0 && shouldOpenBuy)
			{
				CloseAndReset();
			}

			UpdateTrailing(candle);
			CheckRiskExits(candle);
		}

		if (!TryComputeSignals(out var buySignal, out var sellSignal))
		{
			_previousBuyArrow = false;
			_previousSellArrow = false;
			return;
		}

		_previousBuyArrow = buySignal;
		_previousSellArrow = sellSignal;
	}

	private void OpenLong(ICandleMessage candle)
	{
		var volume = GetOrderVolume(candle.ClosePrice);
		if (volume <= 0m)
		return;

		BuyMarket(volume);

		_entryPrice = candle.ClosePrice;
		_stopPrice = StopLossPips > 0 ? candle.ClosePrice - ConvertPips(StopLossPips) : null;
		_takePrice = TakeProfitPips > 0 ? candle.ClosePrice + ConvertPips(TakeProfitPips) : null;
	}

	private void OpenShort(ICandleMessage candle)
	{
		var volume = GetOrderVolume(candle.ClosePrice);
		if (volume <= 0m)
		return;

		SellMarket(volume);

		_entryPrice = candle.ClosePrice;
		_stopPrice = StopLossPips > 0 ? candle.ClosePrice + ConvertPips(StopLossPips) : null;
		_takePrice = TakeProfitPips > 0 ? candle.ClosePrice - ConvertPips(TakeProfitPips) : null;
	}

	private void UpdateTrailing(ICandleMessage candle)
	{
		if (TrailingStopPips <= 0 || _entryPrice == null)
		return;

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

		var step = ConvertPips(TrailingStepPips);

		if (Position > 0)
		{
			var gain = candle.ClosePrice - _entryPrice.Value;
			if (gain > distance + step)
			{
				var newStop = candle.ClosePrice - distance;
				if (!_stopPrice.HasValue || _stopPrice.Value < newStop - step)
				_stopPrice = newStop;
			}
		}
		else if (Position < 0)
		{
			var gain = _entryPrice.Value - candle.ClosePrice;
			if (gain > distance + step)
			{
				var newStop = candle.ClosePrice + distance;
				if (!_stopPrice.HasValue || _stopPrice.Value > newStop + step)
				_stopPrice = newStop;
			}
		}
	}

	private void CheckRiskExits(ICandleMessage candle)
	{
		if (Position > 0)
		{
			var stopHit = _stopPrice.HasValue && candle.LowPrice <= _stopPrice.Value;
			var takeHit = _takePrice.HasValue && candle.HighPrice >= _takePrice.Value;

			if (stopHit || takeHit)
			CloseAndReset();
		}
		else if (Position < 0)
		{
			var stopHit = _stopPrice.HasValue && candle.HighPrice >= _stopPrice.Value;
			var takeHit = _takePrice.HasValue && candle.LowPrice <= _takePrice.Value;

			if (stopHit || takeHit)
			CloseAndReset();
		}
	}

	private void CloseAndReset()
	{
		if (Position > 0)
			SellMarket(Position);
		else if (Position < 0)
			BuyMarket(-Position);
		ResetPositionState();
	}

	private void ResetPositionState()
	{
		_entryPrice = null;
		_stopPrice = null;
		_takePrice = null;
	}

	private void AddCandle(ICandleMessage candle)
	{
		_highSeries.Add(candle.HighPrice);
		_lowSeries.Add(candle.LowPrice);
		_closeSeries.Add(candle.ClosePrice);

		var maxCount = RelayShift + SspPeriod + 5;
		TrimSeries(_highSeries, maxCount);
		TrimSeries(_lowSeries, maxCount);
		TrimSeries(_closeSeries, maxCount);
	}

	private static void TrimSeries(List<decimal> series, int maxCount)
	{
		var excess = series.Count - maxCount;
		if (excess > 0)
		series.RemoveRange(0, excess);
	}

	private bool TryComputeSignals(out bool buySignal, out bool sellSignal)
	{
		buySignal = false;
		sellSignal = false;

		if (_closeSeries.Count <= 1)
		return false;

		var start = RelayShift + 1;
		var end = start + SspPeriod;

		if (end > _highSeries.Count || end > _lowSeries.Count)
		return false;

		var close = GetSeriesValue(_closeSeries, 1);

		decimal high = decimal.MinValue;
		decimal low = decimal.MaxValue;

		for (var i = start; i < end; i++)
		{
			var h = GetSeriesValue(_highSeries, i);
			var l = GetSeriesValue(_lowSeries, i);

			if (h > high)
			high = h;

			if (l < low)
			low = l;
		}

		var range = high - low;
		var smax = high - (low - high) * ChannelPercent / 100m;
		var smin = low + range * ChannelPercent / 100m;
		var innerPercent = ChannelPercent + ChannelStopPercent;
		var smax2 = high - range * innerPercent / 100m;
		var smin2 = low + range * innerPercent / 100m;

		var uptrend = _uptrend;
		var uptrend2 = _uptrend2;
		var old = uptrend;
		var old2 = uptrend2;

		if (close < smin && close < smax && uptrend2)
		uptrend = false;

		if (close > smax && close > smin && !uptrend2)
		uptrend = true;

		if ((close > smax2 || close > smin2) && !uptrend)
		uptrend2 = false;

		if ((close < smin2 || close < smax2) && uptrend)
		uptrend2 = true;

		if (close < smin && close < smax && !uptrend2)
		{
			sellSignal = true;
			uptrend2 = true;
		}

		if (close > smax && close > smin && uptrend2)
		{
			buySignal = true;
			uptrend2 = false;
		}

		if (uptrend != old && !uptrend)
		sellSignal = true;

		if (uptrend != old && uptrend)
		buySignal = true;

		_uptrend = uptrend;
		_uptrend2 = uptrend2;

		return true;
	}

	private static decimal GetSeriesValue(List<decimal> series, int index)
	{
		var targetIndex = series.Count - 1 - index;
		return targetIndex >= 0 ? series[targetIndex] : 0m;
	}

	private decimal GetOrderVolume(decimal price)
	{
		if (VolumeValue > 0m)
		return VolumeValue;

		var portfolio = Portfolio;
		var portfolioValue = portfolio?.CurrentValue ?? portfolio?.BeginValue ?? 0m;
		if (portfolioValue <= 0m || RiskPercent <= 0m)
		return 1m;

		var riskAmount = portfolioValue * RiskPercent / 100m;
		var stopOffset = StopLossPips > 0 ? ConvertPips(StopLossPips) : price * 0.01m;

		if (stopOffset <= 0m)
		return 1m;

		var volume = riskAmount / stopOffset;
		return volume > 0m ? volume : 1m;
	}

	private decimal ConvertPips(int pips)
	{
		if (pips <= 0)
		return 0m;

		var pipSize = GetPipSize();
		return pipSize <= 0m ? 0m : pipSize * pips;
	}

	private decimal GetPipSize()
	{
		var security = Security;
		if (security == null)
		return 0m;

		var step = security.PriceStep ?? 0m;
		if (step <= 0m)
		return 0m;

		var scale = GetDecimalScale(step);
		var factor = scale is 3 or 5 ? 10m : 1m;
		return step * factor;
	}

	private static int GetDecimalScale(decimal value)
	{
		var bits = decimal.GetBits(value);
		return (bits[3] >> 16) & 0xFF;
	}
}