在 GitHub 上查看

E-Skoch-Open 策略(StockSharp 版本)

概览

E-Skoch-Open 策略复刻了原始的 MetaTrader 5 智能交易顾问,其核心是一个三根 K 线收盘价的形态过滤。该移植版本在每根完成的 K 线结束后运行,检测最近收盘价的反转信号,并在条件满足时开仓。风险控制来自以调整后的点值(相当于外汇中的“点”/pip)表示的固定止损和止盈,以及一个基于账户权益增长的总平仓触发器。仓位规模采用马丁格尔方式:亏损后下一笔订单乘以 1.6,而盈利后恢复为初始手数。

交易流程

  1. 使用 CandleType 参数指定的时间框架(默认 1 小时)。
  2. 等待至少三根完成的 K 线形成。
  3. 做多条件:如果 Close[n-3] > Close[n-2]Close[n-1] < Close[n-2],并且允许做多,则开多单。
  4. 做空条件:如果 Close[n-3] > Close[n-2]Close[n-2] < Close[n-1],并且允许做空,则开空单。
  5. CloseOnOppositeSignal 为真时,出现相反信号会立即平掉现有仓位,并且本根 K 线不再开新单。
  6. 每次开仓都会根据当前收盘价和参数设置计算出固定的止损、止盈价格。只要后续完成的 K 线最高/最低价触及其中之一,就会执行平仓。
  7. 策略持续监控组合权益。与上次空仓时相比,当权益增长超过 TargetProfitPercent 百分比时,会立即平掉所有仓位。
  8. 当一次交易以亏损结束时,下一次下单的手数乘以 1.6;若盈利,则重置为初始手数。下单量会根据品种的 VolumeStepVolumeMinVolumeMax 自动进行归一化。

参数说明

参数 说明
CandleType 用于识别形态的时间框架,可使用 StockSharp 支持的任意 K 线类型。
InitialOrderVolume 第一笔订单的基础手数(默认 0.01)。
StopLossPoints 止损距离,以调整后的点值表示;当价格精度为 5 位或 3 位小数时点值为 PriceStep * 10,其他情况为 PriceStep
TakeProfitPoints 止盈距离,采用与止损相同的点值计算。
EnableBuySignals / EnableSellSignals 分别控制是否允许做多或做空。
MaxBuyTrades / MaxSellTrades 每个方向允许的最大连续交易次数,设置为 -1 则不限制。本移植默认每个方向仅持有一个净仓位。
TargetProfitPercent 达到该权益增幅百分比后立即平掉所有仓位(默认 1.2%)。
CloseOnOppositeSignal 若启用,相反信号会先将仓位平掉,然后再等待新的机会。

风险与执行注意事项

  • 止损/止盈是依据完成的 K 线最高/最低价模拟的;在实盘中,服务器端委托的执行顺序可能与 MetaTrader 不同。
  • 1.6 的马丁格尔系数会在回撤中迅速放大仓位,请确保账户保证金和品种 VolumeMax 许可足够的资金使用率。
  • 基于权益的平仓逻辑依赖 Portfolio.CurrentValue,需要交易账户提供实时权益数据。

使用建议

  • 根据原策略的运行周期调整 CandleType
  • StopLossPointsTakeProfitPoints 需要结合品种波动率调整,点值换算由策略自动完成。
  • 若经纪商不允许对冲或风险偏好较低,可关闭单方向交易。
  • 长时间回测或实盘前,请评估权益止盈和马丁格尔组合带来的最大头寸规模。
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 "E-Skoch-Open" MetaTrader strategy using StockSharp high level API.
/// The strategy reacts to a three-candle closing price pattern and applies
/// martingale position sizing together with equity based stops.
/// </summary>
public class ESkochOpenStrategy : Strategy
{
	private readonly StrategyParam<decimal> _martingaleMultiplier;

	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<bool> _enableBuySignals;
	private readonly StrategyParam<bool> _enableSellSignals;
	private readonly StrategyParam<decimal> _targetProfitPercent;
	private readonly StrategyParam<bool> _closeOnOppositeSignal;
	private readonly StrategyParam<int> _maxBuyTrades;
	private readonly StrategyParam<int> _maxSellTrades;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _initialOrderVolume;

	private decimal _pointValue;
	private decimal _currentVolume;
	private decimal _entryEquity;
	private decimal _baselineEquity;
	private bool _positionTracked;

	private decimal? _closeMinus1;
	private decimal? _closeMinus2;
	private decimal? _closeMinus3;

	private decimal? _longStop;
	private decimal? _longTake;
	private decimal? _shortStop;
	private decimal? _shortTake;

	private int _activeLongEntries;
	private int _activeShortEntries;
	private int _previousPatternSignal;

	/// <summary>
	/// Stop loss distance expressed in adjusted points (default: 130).
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Take profit distance expressed in adjusted points (default: 200).
	/// </summary>
	public decimal TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Enables long entries created by the pattern.
	/// </summary>
	public bool EnableBuySignals
	{
		get => _enableBuySignals.Value;
		set => _enableBuySignals.Value = value;
	}

	/// <summary>
	/// Enables short entries created by the pattern.
	/// </summary>
	public bool EnableSellSignals
	{
		get => _enableSellSignals.Value;
		set => _enableSellSignals.Value = value;
	}

	/// <summary>
	/// Equity percentage gain that triggers closing every open position.
	/// </summary>
	public decimal TargetProfitPercent
	{
		get => _targetProfitPercent.Value;
		set => _targetProfitPercent.Value = value;
	}

	/// <summary>
	/// When true, opposite trades immediately flatten the existing position.
	/// </summary>
	public bool CloseOnOppositeSignal
	{
		get => _closeOnOppositeSignal.Value;
		set => _closeOnOppositeSignal.Value = value;
	}

	/// <summary>
	/// Maximum number of consecutive long entries (-1 disables the limit).
	/// </summary>
	public int MaxBuyTrades
	{
		get => _maxBuyTrades.Value;
		set => _maxBuyTrades.Value = value;
	}

	/// <summary>
	/// Maximum number of consecutive short entries (-1 disables the limit).
	/// </summary>
	public int MaxSellTrades
	{
		get => _maxSellTrades.Value;
		set => _maxSellTrades.Value = value;
	}

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

	/// <summary>
	/// Base order volume used for the first trade in a sequence.
	/// </summary>
	public decimal InitialOrderVolume
	{
		get => _initialOrderVolume.Value;
		set => _initialOrderVolume.Value = value;
	}

	/// <summary>
	/// Volume multiplier after losses (martingale).
	/// </summary>
	public decimal MartingaleMultiplier
	{
		get => _martingaleMultiplier.Value;
		set => _martingaleMultiplier.Value = value;
	}

	/// <summary>
	/// Creates the strategy parameters with defaults similar to the MQL version.
	/// </summary>
	public ESkochOpenStrategy()
	{
		_martingaleMultiplier = Param(nameof(MartingaleMultiplier), 1.6m)
			.SetGreaterThanZero()
			.SetDisplay("Martingale Mult", "Volume multiplier after losses", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 130m)
		.SetDisplay("Stop Loss Points", "Loss distance measured in adjusted points", "Risk")
		.SetNotNegative();
		_takeProfitPoints = Param(nameof(TakeProfitPoints), 200m)
		.SetDisplay("Take Profit Points", "Profit distance measured in adjusted points", "Risk")
		.SetNotNegative();
		_enableBuySignals = Param(nameof(EnableBuySignals), true)
		.SetDisplay("Enable Buy", "Allow opening long positions", "Trading");
		_enableSellSignals = Param(nameof(EnableSellSignals), true)
		.SetDisplay("Enable Sell", "Allow opening short positions", "Trading");
		_targetProfitPercent = Param(nameof(TargetProfitPercent), 1.2m)
		.SetDisplay("Target Profit %", "Close all positions after reaching this equity growth", "Risk")
		.SetNotNegative();
		_closeOnOppositeSignal = Param(nameof(CloseOnOppositeSignal), false)
		.SetDisplay("Close On Opposite", "Close open positions when an opposite signal appears", "Trading");
		_maxBuyTrades = Param(nameof(MaxBuyTrades), 1)
		.SetDisplay("Max Long Trades", "Maximum concurrent long trades", "Risk");
		_maxSellTrades = Param(nameof(MaxSellTrades), 1)
		.SetDisplay("Max Short Trades", "Maximum concurrent short trades", "Risk");
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe used for pattern recognition", "Data");
		_initialOrderVolume = Param(nameof(InitialOrderVolume), 0.01m)
		.SetDisplay("Initial Volume", "Volume of the first trade", "Trading")
		.SetGreaterThanZero();
	}

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

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

		_closeMinus1 = null;
		_closeMinus2 = null;
		_closeMinus3 = null;
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
		_activeLongEntries = 0;
		_activeShortEntries = 0;
		_positionTracked = false;
		_pointValue = 0m;
		_currentVolume = 0m;
		_entryEquity = 0m;
		_baselineEquity = 0m;
		_previousPatternSignal = 0;
	}

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

		Volume = InitialOrderVolume;
		_pointValue = CalculatePointValue();
		_currentVolume = NormalizeVolume(InitialOrderVolume);

		var equity = Portfolio?.CurrentValue ?? 0m;
		_baselineEquity = equity;
		_entryEquity = equity;
		_positionTracked = Position != 0;

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

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

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


		CheckEquityTarget();

		if (CheckProtection(candle))
		{
			// Skip new entries if a protection exit already triggered on this bar.
			UpdateCloses(candle.ClosePrice);
			return;
		}

		// removed IsFormedAndOnlineAndAllowTrading check for backtesting

		if (_closeMinus1.HasValue && _closeMinus2.HasValue && _closeMinus3.HasValue)
		{
			var close1 = _closeMinus1.Value;
			var close2 = _closeMinus2.Value;
			var close3 = _closeMinus3.Value;

			var buySignal = close3 > close2 && close1 < close2;
			var sellSignal = close3 > close2 && close2 < close1;

			var patternSignal = buySignal ? 1 : sellSignal ? -1 : 0;

			if (buySignal && patternSignal != _previousPatternSignal)
			{
				HandleBuySignal(candle);
			}

			if (sellSignal && patternSignal != _previousPatternSignal)
			{
				HandleSellSignal(candle);
			}

			_previousPatternSignal = patternSignal;
		}
		else
		{
			_previousPatternSignal = 0;
		}

		UpdateCloses(candle.ClosePrice);
	}

	private void HandleBuySignal(ICandleMessage candle)
	{
		if (!EnableBuySignals)
		{
			return;
		}

		if (CloseOnOppositeSignal && Position < 0)
		{
			BuyMarket(Math.Abs(Position));
			return;
		}

		if (Position > 0)
		{
			return;
		}

		if (MaxBuyTrades != -1 && _activeLongEntries >= MaxBuyTrades)
		{
			return;
		}

		var volume = NormalizeVolume(_currentVolume);
		if (volume <= 0m)
		{
			return;
		}

		BuyMarket(volume);
		_activeLongEntries++;
		_positionTracked = true;
		_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		SetupProtection(true, candle.ClosePrice);
	}

	private void HandleSellSignal(ICandleMessage candle)
	{
		if (!EnableSellSignals)
		{
			return;
		}

		if (CloseOnOppositeSignal && Position > 0)
		{
			SellMarket(Math.Abs(Position));
			return;
		}

		if (Position < 0)
		{
			return;
		}

		if (MaxSellTrades != -1 && _activeShortEntries >= MaxSellTrades)
		{
			return;
		}

		var volume = NormalizeVolume(_currentVolume);
		if (volume <= 0m)
		{
			return;
		}

		SellMarket(volume);
		_activeShortEntries++;
		_positionTracked = true;
		_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		SetupProtection(false, candle.ClosePrice);
	}

	private bool CheckProtection(ICandleMessage candle)
	{
		if (Position > 0)
		{
			if (_longStop.HasValue && candle.LowPrice <= _longStop.Value)
			{
				SellMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}

			if (_longTake.HasValue && candle.HighPrice >= _longTake.Value)
			{
				SellMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}
		}
		else if (Position < 0)
		{
			if (_shortStop.HasValue && candle.HighPrice >= _shortStop.Value)
			{
				BuyMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}

			if (_shortTake.HasValue && candle.LowPrice <= _shortTake.Value)
			{
				BuyMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}
		}

		return false;
	}

	private void SetupProtection(bool isLong, decimal referencePrice)
	{
		var point = _pointValue;
		if (point <= 0m)
		{
			point = Security?.PriceStep ?? 0m;
		}

		if (isLong)
		{
			_longStop = StopLossPoints > 0m ? referencePrice - StopLossPoints * point : null;
			_longTake = TakeProfitPoints > 0m ? referencePrice + TakeProfitPoints * point : null;
			_shortStop = null;
			_shortTake = null;
		}
		else
		{
			_shortStop = StopLossPoints > 0m ? referencePrice + StopLossPoints * point : null;
			_shortTake = TakeProfitPoints > 0m ? referencePrice - TakeProfitPoints * point : null;
			_longStop = null;
			_longTake = null;
		}
	}

	private void ResetProtection()
	{
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
	}

	private void UpdateCloses(decimal close)
	{
		_closeMinus3 = _closeMinus2;
		_closeMinus2 = _closeMinus1;
		_closeMinus1 = close;
	}

	private void CheckEquityTarget()
	{
		if (TargetProfitPercent <= 0m)
		{
			return;
		}

		if (_baselineEquity <= 0m)
		{
			return;
		}

		var equity = Portfolio?.CurrentValue ?? 0m;
		var growthPercent = (equity - _baselineEquity) / _baselineEquity * 100m;

		if (growthPercent >= TargetProfitPercent)
		{
			CloseAllPositions();
		}
	}

	private void CloseAllPositions()
	{
		if (Position > 0)
		{
			SellMarket(Math.Abs(Position));
		}
		else if (Position < 0)
		{
			BuyMarket(Math.Abs(Position));
		}
	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		if (Position == 0)
		{
			if (_positionTracked)
			{
				var equity = Portfolio?.CurrentValue ?? _baselineEquity;
				if (equity >= _entryEquity)
				{
					_currentVolume = NormalizeVolume(InitialOrderVolume);
				}
				else
				{
					_currentVolume = NormalizeVolume(_currentVolume * MartingaleMultiplier);
				}

				_baselineEquity = equity;
				_positionTracked = false;
				ResetProtection();
				_activeLongEntries = 0;
				_activeShortEntries = 0;
			}
			else
			{
				_baselineEquity = Portfolio?.CurrentValue ?? _baselineEquity;
			}
		}
		else
		{
			_positionTracked = true;
			_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		}
	}

	private decimal NormalizeVolume(decimal volume)
	{
		if (volume <= 0m)
		{
			return 0m;
		}

		var sec = Security;
		if (sec != null)
		{
			var step = sec.VolumeStep ?? 0m;
			if (step > 0m)
			{
				volume = Math.Floor(volume / step) * step;
			}

			var min = sec.MinVolume ?? 0m;
			if (min > 0m && volume < min)
			{
				volume = min;
			}

			var max = sec.MaxVolume ?? 0m;
			if (max > 0m && volume > max)
			{
				volume = max;
			}
		}

		return volume;
	}

	private decimal CalculatePointValue()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step <= 0m)
		{
			return 0m;
		}

		var decimals = CountDecimals(step);
		return decimals == 3 || decimals == 5 ? step * 10m : step;
	}

	private static int CountDecimals(decimal value)
	{
		value = Math.Abs(value);
		var decimals = 0;

		while (value != Math.Truncate(value) && decimals < 10)
		{
			value *= 10m;
			decimals++;
		}

		return decimals;
	}
}