Открыть на GitHub

Стратегия Firebird Channel Averaging

Обзор

Стратегия Firebird Channel Averaging переносит логику эксперта MetaTrader 5 «Firebird v0.60» на высокоуровневый API StockSharp. Торговля ведётся по настраиваемому каналу вокруг скользящей средней, а позиции усредняются по мере ухода цены от канала. Решение ориентировано на форекс-стратегии возврата к среднему с сеточным набором позиций и фиксированными рисками в пунктах.

Настройка индикаторов

  • Рассчитывается выбранная скользящая средняя (простая, экспоненциальная, сглаженная или взвешенная). Источник цены свечи (close, high, low, median и т.д.) задаётся параметром.
  • Верхняя и нижняя границы канала формируются путём умножения значения скользящей средней на заданный процент.

Логика входа

  1. Покупка
    • Закрытие выбранной цены свечи ниже нижней границы канала.
    • Либо позиция отсутствует, либо новая заявка удалена от последнего входа минимум на Step (pips) с учётом роста шага через Step Exponent.
    • Между открытиями должно пройти не менее двух свечных интервалов.
  2. Продажа
    • Цена закрывается выше верхней границы канала.
    • Проверки дистанции и таймаута полностью аналогичны длинной стороне.

При выполнении условий отправляется рыночная заявка указанным объёмом. Одновременно удерживается только одно направление: противоположные сигналы ждут закрытия текущей позиции по стопу или тейк-профиту.

Управление позицией

  • Все входы сохраняются, что позволяет вычислять среднюю цену сетки.
  • Стоп-лосс и тейк-профит задаются в пунктах. Для одиночной позиции стоп равен цене входа минус/плюс Stop Loss (pips), тейк — цене входа плюс/минус Take Profit (pips).
  • При нескольких позициях расстояние до стопа делится на число входов, что повторяет механику усреднения оригинального эксперта.
  • Тейк-профит фиксирован относительно средней цены, а стоп пересчитывается на каждой свече.
  • Торговлю по пятницам можно отключить отдельным параметром.

Параметры

Параметр Описание
Volume Объём каждой рыночной заявки (по умолчанию 0.1 лота).
Stop Loss (pips) Дистанция стоп-лосса в пунктах (по умолчанию 50).
Take Profit (pips) Дистанция тейк-профита в пунктах (по умолчанию 150).
MA Period Период расчёта скользящей средней (по умолчанию 10).
MA Shift Сдвиг значения скользящей средней вперёд на указанное число свечей.
MA Type Тип скользящей: Simple, Exponential, Smoothed или Weighted.
Price Source Цена свечи, используемая в индикаторе (по умолчанию close).
Channel % Процентное смещение каналов относительно скользящей средней (по умолчанию 0.3%).
Trade Friday Разрешение на торговлю в пятницу.
Step (pips) Минимальный шаг между усредняющими входами (по умолчанию 30 пунктов).
Step Exponent Показатель степени, увеличивающий шаг в зависимости от количества позиций (0 — фиксированный шаг).
Candle Type Рабочий таймфрейм свечей.

Дополнительные замечания

  • Стратегия считает, что PriceStep инструмента равен одному пункту; при отсутствии значения используется 0.0001.
  • Защитные выходы исполняются рыночными заявками, что соответствует возможностям высокоуровневого API.
  • Холодный период и увеличивающийся шаг ограничивают размер сетки и предотвращают чрезмерное наращивание позиции.
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>
/// Firebird grid strategy that trades price deviations from a moving average channel
/// and averages into positions at configurable pip intervals.
/// </summary>
public class FirebirdChannelAveragingStrategy : Strategy
{
	/// <summary>
	/// Moving average calculation modes supported by the strategy.
	/// </summary>
	public enum MovingAverageTypes
	{
		/// <summary>
		/// Simple moving average.
		/// </summary>
		Simple,

		/// <summary>
		/// Exponential moving average.
		/// </summary>
		Exponential,

		/// <summary>
		/// Smoothed moving average.
		/// </summary>
		Smoothed,

		/// <summary>
		/// Weighted moving average.
		/// </summary>
		Weighted
	}

	public enum CandlePrices
	{
		Open,
		High,
		Low,
		Close,
		Median,
		Typical,
		Weighted
	}

	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<int> _maPeriod;
	private readonly StrategyParam<int> _maShift;
	private readonly StrategyParam<MovingAverageTypes> _maType;
	private readonly StrategyParam<CandlePrices> _priceSource;
	private readonly StrategyParam<decimal> _pricePercent;
	private readonly StrategyParam<bool> _tradeOnFriday;
	private readonly StrategyParam<int> _stepPips;
	private readonly StrategyParam<decimal> _stepExponent;
	private readonly StrategyParam<DataType> _candleType;

	private DecimalLengthIndicator _ma;
	private readonly Queue<decimal> _maHistory = new();
	private readonly List<PositionEntry> _entries = new();
	private bool? _isLong;
	private DateTimeOffset? _lastEntryTime;


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

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

	/// <summary>
	/// Moving average lookback period.
	/// </summary>
	public int MaPeriod
	{
		get => _maPeriod.Value;
		set => _maPeriod.Value = value;
	}

	/// <summary>
	/// Forward shift applied to the moving average in candles.
	/// </summary>
	public int MaShift
	{
		get => _maShift.Value;
		set => _maShift.Value = value;
	}

	/// <summary>
	/// Moving average calculation mode.
	/// </summary>
	public MovingAverageTypes MaType
	{
		get => _maType.Value;
		set => _maType.Value = value;
	}

	/// <summary>
	/// Candle price source used for the moving average and signal checks.
	/// </summary>
	public CandlePrices PriceSource
	{
		get => _priceSource.Value;
		set => _priceSource.Value = value;
	}

	/// <summary>
	/// Channel width as percentage offset from the moving average.
	/// </summary>
	public decimal PricePercent
	{
		get => _pricePercent.Value;
		set => _pricePercent.Value = value;
	}

	/// <summary>
	/// Enables trading on Fridays.
	/// </summary>
	public bool TradeOnFriday
	{
		get => _tradeOnFriday.Value;
		set => _tradeOnFriday.Value = value;
	}

	/// <summary>
	/// Minimum distance between averaged entries expressed in pips.
	/// </summary>
	public int StepPips
	{
		get => _stepPips.Value;
		set => _stepPips.Value = value;
	}

	/// <summary>
	/// Exponent controlling how the averaging step grows with position count.
	/// </summary>
	public decimal StepExponent
	{
		get => _stepExponent.Value;
		set => _stepExponent.Value = value;
	}

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

	/// <summary>
	/// Initialize <see cref="FirebirdChannelAveragingStrategy"/>.
	/// </summary>
	public FirebirdChannelAveragingStrategy()
	{

		_stopLossPips = Param(nameof(StopLossPips), 50)
			.SetGreaterThanZero()
			.SetDisplay("Stop Loss (pips)", "Stop loss distance in pips", "Risk")
			
			.SetOptimize(20, 150, 10);

		_takeProfitPips = Param(nameof(TakeProfitPips), 150)
			.SetGreaterThanZero()
			.SetDisplay("Take Profit (pips)", "Take profit distance in pips", "Risk")
			
			.SetOptimize(50, 300, 10);

		_maPeriod = Param(nameof(MaPeriod), 10)
			.SetGreaterThanZero()
			.SetDisplay("MA Period", "Moving average length", "Indicator")
			
			.SetOptimize(5, 30, 1);

		_maShift = Param(nameof(MaShift), 0)
			.SetNotNegative()
			.SetDisplay("MA Shift", "Forward shift for moving average", "Indicator");

		_maType = Param(nameof(MaType), MovingAverageTypes.Exponential)
			.SetDisplay("MA Type", "Moving average calculation mode", "Indicator");

		_priceSource = Param(nameof(PriceSource), CandlePrices.Close)
			.SetDisplay("Price Source", "Candle price used for signals", "Data");

		_pricePercent = Param(nameof(PricePercent), 0.3m)
			.SetGreaterThanZero()
			.SetDisplay("Channel %", "Channel width percentage", "Indicator")
			
			.SetOptimize(0.1m, 1m, 0.1m);

		_tradeOnFriday = Param(nameof(TradeOnFriday), true)
			.SetDisplay("Trade Friday", "Allow trading on Fridays", "Risk");

		_stepPips = Param(nameof(StepPips), 30)
			.SetGreaterThanZero()
			.SetDisplay("Step (pips)", "Distance between averaged entries", "Grid")
			
			.SetOptimize(10, 60, 5);

		_stepExponent = Param(nameof(StepExponent), 0m)
			.SetNotNegative()
			.SetDisplay("Step Exponent", "Power growth for step size", "Grid")
			
			.SetOptimize(0m, 2m, 0.5m);

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Working timeframe", "Data");
	}

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

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

		_entries.Clear();
		_maHistory.Clear();
		_isLong = null;
		_lastEntryTime = null;
	}

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

		_ma = CreateMovingAverage(MaType);
		_ma.Length = MaPeriod;

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

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

	}

	private void ProcessCandle(ICandleMessage candle, decimal maValue)
	{
		// Only work with closed candles to avoid intra-bar noise.
		if (candle.State != CandleStates.Finished)
		{
			return;
		}

		// Ensure the moving average has enough historical data.
		if (_ma == null || !_ma.IsFormed)
		{
			return;
		}

		var shiftedValue = ApplyShift(maValue);
		if (shiftedValue is null)
		{
			return;
		}

		var price = GetCandlePrice(candle);
		var ma = shiftedValue.Value;

		var lowerBand = ma * (1m - PricePercent / 100m);
		var upperBand = ma * (1m + PricePercent / 100m);

		var allowEntry = TradeOnFriday || candle.OpenTime.DayOfWeek != DayOfWeek.Friday;

		if (!IsOnline)
		{
			allowEntry = false;
		}

		var pipSize = GetPipSize();
		var baseStep = StepPips * pipSize;
		if (baseStep <= 0)
		{
			baseStep = pipSize;
		}

		var entriesCount = _entries.Count;
		var stepMultiplier = StepExponent <= 0m
			? 1m
			: (decimal)Math.Pow(Math.Max(entriesCount, 1), (double)StepExponent);
		var currentStep = baseStep * stepMultiplier;
		if (currentStep <= 0)
		{
			currentStep = baseStep;
		}

		var canOpenByTime = true;
		var timeFrame = GetTimeFrame();
		var lastEntryTime = _lastEntryTime;
		if (entriesCount > 0 && lastEntryTime.HasValue && timeFrame != null)
		{
			var minDelay = timeFrame.Value + timeFrame.Value;
			canOpenByTime = candle.CloseTime - lastEntryTime.Value >= minDelay;
		}

		if (allowEntry)
		{
			TryOpenLong(candle, price, lowerBand, currentStep, canOpenByTime);
			TryOpenShort(candle, price, upperBand, currentStep, canOpenByTime);
		}

		ManageOpenPositions(candle, price, pipSize);
	}

	private void TryOpenLong(ICandleMessage candle, decimal price, decimal lowerBand, decimal currentStep, bool canOpenByTime)
	{
		if (price >= lowerBand)
		{
			return;
		}

		if (_entries.Count > 0 && _isLong != true)
		{
			return;
		}

		if (_entries.Count > 0 && !canOpenByTime)
		{
			return;
		}

		if (_entries.Count > 0)
		{
			var lastEntry = _entries[_entries.Count - 1];
			if (price > lastEntry.Price - currentStep)
			{
				return;
			}
		}

		BuyMarket(Volume);

		var entry = new PositionEntry
		{
			Price = price,
			Time = candle.CloseTime
		};

		_entries.Add(entry);
		_isLong = true;
		_lastEntryTime = entry.Time;
	}

	private void TryOpenShort(ICandleMessage candle, decimal price, decimal upperBand, decimal currentStep, bool canOpenByTime)
	{
		if (price <= upperBand)
		{
			return;
		}

		if (_entries.Count > 0 && _isLong != false)
		{
			return;
		}

		if (_entries.Count > 0 && !canOpenByTime)
		{
			return;
		}

		if (_entries.Count > 0)
		{
			var lastEntry = _entries[_entries.Count - 1];
			if (price < lastEntry.Price + currentStep)
			{
				return;
			}
		}

		SellMarket(Volume);

		var entry = new PositionEntry
		{
			Price = price,
			Time = candle.CloseTime
		};

		_entries.Add(entry);
		_isLong = false;
		_lastEntryTime = entry.Time;
	}

	private void ManageOpenPositions(ICandleMessage candle, decimal price, decimal pipSize)
	{
		var entriesCount = _entries.Count;
		if (entriesCount == 0)
		{
			return;
		}

		if (pipSize <= 0)
		{
			pipSize = 0.0001m;
		}

		var stopDistance = StopLossPips * pipSize;
		var takeDistance = TakeProfitPips * pipSize;

		decimal averagePrice = 0m;
		for (var i = 0; i < _entries.Count; i++)
		{
			averagePrice += _entries[i].Price;
		}
		if (entriesCount == 0)
		{
			return;
		}

		averagePrice /= entriesCount;

		if (_isLong == true)
		{
			var stopPrice = stopDistance > 0
			? averagePrice - (entriesCount > 1 ? stopDistance / entriesCount : stopDistance)
			: averagePrice;
			var takePrice = takeDistance > 0 ? averagePrice + takeDistance : decimal.MaxValue;

			if (price <= stopPrice)
			{
				CloseLongPositions();
				return;
			}

			if (price >= takePrice)
			{
				CloseLongPositions();
			}
		}
		else if (_isLong == false)
		{
			var stopPrice = stopDistance > 0
			? averagePrice + (entriesCount > 1 ? stopDistance / entriesCount : stopDistance)
			: averagePrice;
			var takePrice = takeDistance > 0 ? averagePrice - takeDistance : decimal.MinValue;

			if (price >= stopPrice)
			{
				CloseShortPositions();
				return;
			}

			if (price <= takePrice)
			{
				CloseShortPositions();
			}
		}
	}

	private void CloseLongPositions()
	{
		var volume = Position;
		if (volume > 0)
		{
			SellMarket(volume);
		}

		ResetEntries();
	}

	private void CloseShortPositions()
	{
		var volume = Math.Abs(Position);
		if (volume > 0)
		{
			BuyMarket(volume);
		}

		ResetEntries();
	}

	private void ResetEntries()
	{
		_entries.Clear();
		_isLong = null;
		_lastEntryTime = null;
	}

	private decimal? ApplyShift(decimal maValue)
	{
		var shift = MaShift;
		if (shift <= 0)
		{
			return maValue;
		}

		_maHistory.Enqueue(maValue);

		if (_maHistory.Count <= shift)
		{
			return null;
		}

		while (_maHistory.Count > shift + 1)
		{
			_maHistory.Dequeue();
		}

		return _maHistory.Peek();
	}

	private DecimalLengthIndicator CreateMovingAverage(MovingAverageTypes type)
	{
		return type switch
		{
			MovingAverageTypes.Simple => new SimpleMovingAverage(),
			MovingAverageTypes.Smoothed => new SmoothedMovingAverage(),
			MovingAverageTypes.Weighted => new WeightedMovingAverage(),
			_ => new ExponentialMovingAverage()
		};
	}

	private decimal GetCandlePrice(ICandleMessage candle)
	{
		return PriceSource switch
		{
			CandlePrices.Open => candle.OpenPrice,
			CandlePrices.High => candle.HighPrice,
			CandlePrices.Low => candle.LowPrice,
			CandlePrices.Close => candle.ClosePrice,
			CandlePrices.Median => (candle.HighPrice + candle.LowPrice) / 2m,
			CandlePrices.Typical => (candle.HighPrice + candle.LowPrice + candle.ClosePrice) / 3m,
			CandlePrices.Weighted => (candle.HighPrice + candle.LowPrice + 2m * candle.ClosePrice) / 4m,
			_ => candle.ClosePrice
		};
	}

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

		if (security.PriceStep is > 0)
		{
			return security.PriceStep.Value;
		}

		return 0.0001m;
	}

	private TimeSpan? GetTimeFrame()
	{
		return CandleType.Arg is TimeSpan span ? span : null;
	}

	private sealed class PositionEntry
	{
		public decimal Price { get; set; }

		public DateTimeOffset Time { get; set; }
	}
}