在 GitHub 上查看

Firebird通道均值策略

概述

Firebird通道均值策略在StockSharp高阶API上重现MetaTrader 5专家顾问“Firebird v0.60”。策略基于可配置的均线通道进行交易,当价格远离通道时逐步加仓,实现典型的外汇均值回归与网格化风格,并通过以点数计的风控参数控制风险。

指标设置

  • 计算一种可选的移动平均线(简单、指数、平滑或加权)作为基础线,价格来源(收盘价、高价、低价、中位价等)可配置。
  • 将移动平均线按用户定义的百分比向上和向下偏移,得到上下通道边界。

入场逻辑

  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 用于指标计算的蜡烛价格(默认收盘价)。
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; }
	}
}