在 GitHub 上查看

N根同向K线策略

概述

N根同向K线策略复刻了 MetaTrader 专家顾问 “N-_Candles_v7” 的核心逻辑,并基于 StockSharp 高阶 API 重新实现。策略在每根K线收盘后统计连续的多头或空头实体数量,当连续数量达到设定阈值时,在同方向开仓,并结合止盈、止损、跟踪止损、交易时段过滤以及浮动盈亏保护进行仓位管理。

交易逻辑

  • 对每根已收盘K线进行分类:看涨、看跌或十字星。十字星会重置计数,并可能触发“黑羊”处理逻辑。
  • 维护连续同向K线的计数器,一旦达到设定的 ConsecutiveCandles 值,就认为出现有效的趋势模式。
  • 在看涨序列有效时尝试开多头仓位;看跌序列有效时尝试开空头仓位。策略始终只持有一个净头寸。
  • 当出现破坏序列方向的“黑羊”K线时,根据 ClosingBehavior 的设置执行不同的平仓方案:全部平仓、仅平掉与序列方向相反的仓位或仅平掉顺势仓位。
  • 可选地启用交易时段过滤,仅在 StartHourEndHour(含端点)之间允许入场。
  • 持仓期间持续监测止盈、止损、跟踪止损,以及 MinProfit 配置的浮动盈利阈值。

风险与仓位管理

  • 止损与止盈距离以“点”(pip)为单位,并结合品种的 PriceStep 换算成价格距离,每次开仓时重新计算。
  • 跟踪止损规则与原版EA一致:当价格前进超过“跟踪距离 + 跟踪步长”时,将止损上移/下移以保持既定距离。
  • 浮动盈利守护 (MinProfit) 在未实现盈亏超过阈值时立即平掉全部仓位。
  • MaxPositionVolume 限制下单手数,MaxPositions 防止在已有净仓位时再次开仓(本实现采用净仓机制)。
  • 所有离场动作均通过市价单完成,以适配 StockSharp 的净持仓模型。

参数说明

参数 说明
ConsecutiveCandles 触发信号所需的连续同方向K线数量。
OrderVolume 每次下单的手数。
TakeProfitPips 止盈距离(pip),设为0则关闭止盈。
StopLossPips 止损距离(pip),设为0则关闭止损。
TrailingStopPips 跟踪止损的基础距离,设为0则关闭跟踪。
TrailingStepPips 触发更新跟踪止损所需的额外距离。
MaxPositions 同一方向允许的最大净仓位数(策略保持单一净仓位)。
MaxPositionVolume 可接受的最大净手数。
UseTradeHours / StartHour / EndHour 是否启用及设置交易时段(含起止小时)。
MinProfit 达到该浮动盈利后立即平仓。
ClosingBehavior “黑羊”出现时的平仓方式。
CandleType 用于计算的K线类型。

说明与假设

  • 策略仅使用净仓位,无法像原EA那样在同一方向叠加多笔对冲仓位。
  • 浮动盈亏按 (当前价格 - 入场价格) * 手数(多头)或反向公式(空头)估算。
  • 点值转换依赖于品种的 PriceStep。若未提供最小跳动,则默认使用 0.0001。
  • 按要求本次仅提供 C# 版本,未创建 Python 版本或目录。
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>
/// Strategy that searches for N identical candles in a row and trades in the direction of the streak.
/// Implements optional take profit, stop loss, trailing stop, trading hours filter and profit lock.
/// </summary>
public class NCandlesSequenceStreakStrategy : Strategy
{
	private readonly StrategyParam<int> _consecutiveCandles;
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _trailingStopPips;
	private readonly StrategyParam<int> _trailingStepPips;
	private readonly StrategyParam<int> _maxPositions;
	private readonly StrategyParam<decimal> _maxPositionVolume;
	private readonly StrategyParam<bool> _useTradeHours;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _endHour;
	private readonly StrategyParam<decimal> _minProfit;
	private readonly StrategyParam<ClosingModes> _closingBehavior;
	private readonly StrategyParam<DataType> _candleType;

	private int _streakCount;
	private int _lastDirection;
	private int _patternDirection;
	private int _entriesInDirection;
	private bool _blackSheepTriggered;
	private bool _hasPosition;
	private decimal _entryPrice;
	private decimal _pipSize;
	private decimal? _longStop;
	private decimal? _longTake;
	private decimal? _shortStop;
	private decimal? _shortTake;

	/// <summary>
	/// Defines how positions are closed when a "black sheep" candle appears.
	/// </summary>
	public enum ClosingModes
	{
		/// <summary>Close every open position.</summary>
		All,

		/// <summary>Close positions opposite to the previously detected streak.</summary>
		Opposite,

		/// <summary>Close positions that follow the previously detected streak.</summary>
		SameDirection
	}

	/// <summary>
	/// Initializes a new instance of the <see cref="NCandlesSequenceStreakStrategy"/> class.
	/// </summary>
	public NCandlesSequenceStreakStrategy()
	{
		_consecutiveCandles = Param(nameof(ConsecutiveCandles), 4)
		.SetGreaterThanZero()
		.SetDisplay("Consecutive Candles", "Number of candles with identical direction", "Pattern")
		
		.SetOptimize(2, 10, 1);

		_orderVolume = Param(nameof(OrderVolume), 0.01m)
		.SetGreaterThanZero()
		.SetDisplay("Order Volume", "Volume used for market orders", "Risk");

		_takeProfitPips = Param(nameof(TakeProfitPips), 50)
		.SetGreaterThanZero()
		.SetDisplay("Take Profit (pips)", "Distance for the take profit target", "Risk")
		
		.SetOptimize(10, 200, 10);

		_stopLossPips = Param(nameof(StopLossPips), 50)
		.SetGreaterThanZero()
		.SetDisplay("Stop Loss (pips)", "Distance for the protective stop", "Risk")
		
		.SetOptimize(10, 200, 10);

		_trailingStopPips = Param(nameof(TrailingStopPips), 10)
		.SetDisplay("Trailing Stop (pips)", "Distance used when trailing is active", "Risk");

		_trailingStepPips = Param(nameof(TrailingStepPips), 4)
		.SetDisplay("Trailing Step (pips)", "Additional distance before moving the trailing stop", "Risk");

		_maxPositions = Param(nameof(MaxPositions), 2)
		.SetGreaterThanZero()
		.SetDisplay("Max Positions", "Maximum number of sequential entries in the same direction", "Risk");

		_maxPositionVolume = Param(nameof(MaxPositionVolume), 2m)
		.SetGreaterThanZero()
		.SetDisplay("Max Position Volume", "Maximum volume allowed for an open position", "Risk");

		_useTradeHours = Param(nameof(UseTradeHours), false)
		.SetDisplay("Use Trade Hours", "Enable intraday trading window", "Timing");

		_startHour = Param(nameof(StartHour), 11)
		.SetDisplay("Start Hour", "First trading hour (0-23)", "Timing");

		_endHour = Param(nameof(EndHour), 18)
		.SetDisplay("End Hour", "Last trading hour (0-23)", "Timing");

		_minProfit = Param(nameof(MinProfit), 3m)
		.SetDisplay("Min Profit", "Close positions when floating profit exceeds this value", "Risk")
		
		.SetOptimize(1m, 20m, 1m);

		_closingBehavior = Param(nameof(ClosingBehavior), ClosingModes.All)
		.SetDisplay("Black Sheep Closing", "Reaction when the streak is broken", "Pattern");

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Type of candles to analyze", "General");
	}

	/// <summary>
	/// Required number of candles with the same direction.
	/// </summary>
	public int ConsecutiveCandles
	{
		get => _consecutiveCandles.Value;
		set => _consecutiveCandles.Value = value;
	}

	/// <summary>
	/// Volume for market orders.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Take profit distance expressed in pips.
	/// </summary>
	public int TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Stop loss distance expressed in pips.
	/// </summary>
	public int StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Trailing stop distance in pips.
	/// </summary>
	public int TrailingStopPips
	{
		get => _trailingStopPips.Value;
		set => _trailingStopPips.Value = value;
	}

	/// <summary>
	/// Additional step before the trailing stop is moved.
	/// </summary>
	public int TrailingStepPips
	{
		get => _trailingStepPips.Value;
		set => _trailingStepPips.Value = value;
	}

	/// <summary>
	/// Maximum number of consecutive entries in the same direction.
	/// </summary>
	public int MaxPositions
	{
		get => _maxPositions.Value;
		set => _maxPositions.Value = value;
	}

	/// <summary>
	/// Maximum allowed volume for an open position.
	/// </summary>
	public decimal MaxPositionVolume
	{
		get => _maxPositionVolume.Value;
		set => _maxPositionVolume.Value = value;
	}

	/// <summary>
	/// Enables the trading hours filter.
	/// </summary>
	public bool UseTradeHours
	{
		get => _useTradeHours.Value;
		set => _useTradeHours.Value = value;
	}

	/// <summary>
	/// First trading hour (inclusive).
	/// </summary>
	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	/// <summary>
	/// Last trading hour (inclusive).
	/// </summary>
	public int EndHour
	{
		get => _endHour.Value;
		set => _endHour.Value = value;
	}

	/// <summary>
	/// Minimum floating profit that forces the strategy to close all positions.
	/// </summary>
	public decimal MinProfit
	{
		get => _minProfit.Value;
		set => _minProfit.Value = value;
	}

	/// <summary>
	/// How to handle positions when the streak is broken.
	/// </summary>
	public ClosingModes ClosingBehavior
	{
		get => _closingBehavior.Value;
		set => _closingBehavior.Value = value;
	}

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

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

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

	private void ResetState()
	{
		_streakCount = 0;
		_lastDirection = 0;
		_patternDirection = 0;
		_entriesInDirection = 0;
		_blackSheepTriggered = false;
		_hasPosition = false;
		_entryPrice = 0m;
		_pipSize = 0m;
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
	}

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

		if (UseTradeHours && StartHour >= EndHour)
		throw new InvalidOperationException("Start hour must be less than end hour when the trading window is enabled.");

		_pipSize = CalculatePipSize();

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

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

		// no indicators to check formation

		UpdateTrailingStops(candle);
		ManageFloatingProfit(candle);

		var direction = GetCandleDirection(candle);

		if (direction == 0)
		{
			HandlePatternBreak();
			_lastDirection = 0;
			_streakCount = 0;
			return;
		}

		if (_lastDirection == direction)
		{
			_streakCount++;
		}
		else
		{
			if (_lastDirection != 0)
			HandlePatternBreak();

			_lastDirection = direction;
			_streakCount = 1;
		}

		if (_streakCount >= ConsecutiveCandles)
		{
			if (_patternDirection != direction)
			{
				_patternDirection = direction;
				_entriesInDirection = 0;
				_blackSheepTriggered = false;
			}

			if (direction > 0)
			TryEnterLong(candle);
			else
			TryEnterShort(candle);
		}

		ManageExits(candle);
	}

	private void ManageFloatingProfit(ICandleMessage candle)
	{
		if (MinProfit <= 0m || Position == 0m || !_hasPosition)
		return;

		var floating = CalculateOpenProfit(candle.ClosePrice);
		if (floating >= MinProfit)
		ClosePosition();
	}

	private void TryEnterLong(ICandleMessage candle)
	{
		if (OrderVolume <= 0m || OrderVolume > MaxPositionVolume)
		return;

		if (_entriesInDirection >= MaxPositions)
		return;

		if (UseTradeHours && !IsWithinTradeHours(candle.CloseTime))
		return;

		if (Position < 0m)
		{
			BuyMarket(Math.Abs(Position));
			ResetPositionState();
			return;
		}

		if (Position != 0m)
		return;

		BuyMarket(OrderVolume);
		InitializeLongState(candle.ClosePrice);
	}

	private void TryEnterShort(ICandleMessage candle)
	{
		if (OrderVolume <= 0m || OrderVolume > MaxPositionVolume)
		return;

		if (_entriesInDirection >= MaxPositions)
		return;

		if (UseTradeHours && !IsWithinTradeHours(candle.CloseTime))
		return;

		if (Position > 0m)
		{
			SellMarket(Position);
			ResetPositionState();
			return;
		}

		if (Position != 0m)
		return;

		SellMarket(OrderVolume);
		InitializeShortState(candle.ClosePrice);
	}

	private void InitializeLongState(decimal entryPrice)
	{
		_hasPosition = true;
		_entryPrice = entryPrice;
		_entriesInDirection = 1;
		_blackSheepTriggered = false;

		var stopDistance = StopLossPips > 0 ? ToPrice(StopLossPips) : (decimal?)null;
		var takeDistance = TakeProfitPips > 0 ? ToPrice(TakeProfitPips) : (decimal?)null;

		_longStop = stopDistance.HasValue ? entryPrice - stopDistance : null;
		_longTake = takeDistance.HasValue ? entryPrice + takeDistance : null;
		_shortStop = null;
		_shortTake = null;
	}

	private void InitializeShortState(decimal entryPrice)
	{
		_hasPosition = true;
		_entryPrice = entryPrice;
		_entriesInDirection = 1;
		_blackSheepTriggered = false;

		var stopDistance = StopLossPips > 0 ? ToPrice(StopLossPips) : (decimal?)null;
		var takeDistance = TakeProfitPips > 0 ? ToPrice(TakeProfitPips) : (decimal?)null;

		_shortStop = stopDistance.HasValue ? entryPrice + stopDistance : null;
		_shortTake = takeDistance.HasValue ? entryPrice - takeDistance : null;
		_longStop = null;
		_longTake = null;
	}

	private void ManageExits(ICandleMessage candle)
	{
		if (!_hasPosition)
		return;

		if (Position > 0m)
		{
			if (_longStop.HasValue && candle.LowPrice <= _longStop.Value)
			{
				SellMarket(Position);
				ResetPositionState();
				return;
			}

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

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

	private void UpdateTrailingStops(ICandleMessage candle)
	{
		if (!_hasPosition || TrailingStopPips <= 0 || _pipSize <= 0m)
		return;

		var distance = ToPrice(TrailingStopPips);
		var step = TrailingStepPips > 0 ? ToPrice(TrailingStepPips) : 0m;

		if (Position > 0m)
		{
			var threshold = candle.ClosePrice - (distance + step);
			if (candle.ClosePrice - _entryPrice > distance + step && (!_longStop.HasValue || _longStop.Value < threshold))
			_longStop = candle.ClosePrice - distance;
		}
		else if (Position < 0m)
		{
			var threshold = candle.ClosePrice + (distance + step);
			if (_entryPrice - candle.ClosePrice > distance + step && (!_shortStop.HasValue || _shortStop.Value > threshold))
			_shortStop = candle.ClosePrice + distance;
		}
	}

	private void HandlePatternBreak()
	{
		if (_patternDirection == 0 || _blackSheepTriggered)
		return;

		switch (ClosingBehavior)
		{
			case ClosingModes.All:
			ClosePosition();
			break;

			case ClosingModes.Opposite:
			if (_patternDirection > 0 && Position < 0m)
			ClosePosition();
			else if (_patternDirection < 0 && Position > 0m)
			ClosePosition();
			break;

			case ClosingModes.SameDirection:
			if (_patternDirection > 0 && Position > 0m)
			ClosePosition();
			else if (_patternDirection < 0 && Position < 0m)
			ClosePosition();
			break;
		}

		_blackSheepTriggered = true;
		_entriesInDirection = 0;
		_patternDirection = 0;
	}

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

		ResetPositionState();
	}

	private void ResetPositionState()
	{
		_hasPosition = Position != 0m;
		_entriesInDirection = _hasPosition ? 1 : 0;

		if (!_hasPosition)
		{
			_entryPrice = 0m;
			_longStop = null;
			_longTake = null;
			_shortStop = null;
			_shortTake = null;
		}
	}

	private decimal CalculateOpenProfit(decimal currentPrice)
	{
		if (!_hasPosition || Position == 0m)
		return 0m;

		var volume = Math.Abs(Position);
		return Position > 0m ? (currentPrice - _entryPrice) * volume : (_entryPrice - currentPrice) * volume;
	}

	private static int GetCandleDirection(ICandleMessage candle)
	{
		if (candle.OpenPrice < candle.ClosePrice)
		return 1;

		if (candle.OpenPrice > candle.ClosePrice)
		return -1;

		return 0;
	}

	private bool IsWithinTradeHours(DateTimeOffset time)
	{
		var hour = time.Hour;
		return hour >= StartHour && hour <= EndHour;
	}

	private decimal ToPrice(int pips)
	{
		return pips * _pipSize;
	}

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

		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;
	}
}