在 GitHub 上查看

Bounce Number 策略

概述

Bounce Number Strategy 是 MetaTrader 指标 BounceNumber_V0.mq4 / BounceNumber_V1.mq4 的 StockSharp 版本。原始指标会在图表上绘制一个统计表,用来展示价格在突破通道之前在通道内往返多少次。C# 实现保留了相同的统计思想:策略订阅蜡烛线,在高、低通道之间检测交替触碰次数,并把每个循环的结果保存到分布表中。

由于 StockSharp 是事件驱动框架,该移植版不再依赖图表对象。所有统计数据通过 BounceDistribution 属性和日志消息提供,可以方便地被外部界面或分析模块消费。

工作流程

  1. 启动时策略会验证交易品种是否设置了 PriceStepChannelPoints 参数与 MQL 中的“点”一致,需要依赖价格步长把整数转换成实际价格距离。
  2. 调用 SubscribeCandles 创建来源于 CandleType 的蜡烛订阅,处理函数只接收状态为 CandleStates.Finished 的完整蜡烛。
  3. 第一根蜡烛的收盘价定义通道中心,半宽度等于 ChannelPoints * PriceStep,上下边界围绕中心对称分布。
  4. 对于每根新的蜡烛,策略执行三项检查:
    • 突破:如果蜡烛区间触及 中心 ± 2 * 半宽度,当前循环结束并记录到分布字典中;
    • 下轨触碰:如果蜡烛跨越下轨且上一次触碰不是下轨,则计数加一并把方向标记为下轨;
    • 上轨触碰:同样的逻辑应用在上轨。
  5. 当循环持续的蜡烛数量超过 MaxHistoryCandles(大于 0 时生效),策略会强制重置通道,避免价格长时间盘整导致统计停滞。
  6. 每次重置都会把结果写入 BounceDistribution 并输出日志,方便用户与其他组件读取数据。

该策略不发送任何订单,定位是行情分析工具。可以将其与自定义的图表或报表系统组合使用,以重现 MetaTrader 的统计面板。

参数

名称 类型 默认值 MQL 对应参数 说明
MaxHistoryCandles int 10000 maxbar 单个循环允许的最大蜡烛数量。设置为 0 可关闭该限制。
ChannelPoints int 300 BPoints 通道半宽度(以点数计)。
CandleType DataType M1 TF 用于计算的蜡烛时间框架。

与 MetaTrader 版本的差异

  • 使用 Dictionary<int, int> 保存直方图,而不是图表文本对象,更利于在 StockSharp 中导出和可视化。
  • 去除了所有与界面配色或按钮相关的参数,它们对计算没有影响。
  • MaxHistoryCandles 可选且适用于实时/历史流,原始脚本仅在扫描有限历史时使用该限制。
  • 所有代码注释和日志信息均为英文,符合当前仓库要求。

使用建议

  • 确认品种元数据中存在有效的 PriceStep,否则策略无法把点值转换成价格偏移。
  • 针对不同波动性场景调整 ChannelPoints:短线策略可以使用较小的点数,长周期或波动较大的市场可以使用更宽的通道。
  • 需要重放历史统计时,可在连接器中启用 HistoryBuildMode,让策略自动处理回放的蜡烛数据。
  • 若要复刻原指标的统计表,可在界面层读取 BounceDistribution 并自行渲染为表格或柱状图。
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 "BounceNumber" MetaTrader indicator that counts how many times price bounces inside a channel before breaking it.
/// The strategy keeps track of the touch statistics and logs the distribution after each completed cycle.
/// </summary>
public class BounceNumberStrategy : Strategy
{
	private readonly StrategyParam<int> _maxHistoryCandles;
	private readonly StrategyParam<int> _channelPoints;
	private readonly StrategyParam<DataType> _candleType;

	private readonly Dictionary<int, int> _bounceDistribution = new();

	private decimal? _channelCenter;
	private int _bounceCount;
	private int _lastTouchDirection;
	private int _candlesInCycle;

	/// <summary>
	/// Maximum number of candles allowed inside one channel cycle before it is forcefully reset.
	/// </summary>
	public int MaxHistoryCandles
	{
		get => _maxHistoryCandles.Value;
		set => _maxHistoryCandles.Value = value;
	}

	/// <summary>
	/// Half-width of the bounce channel expressed in price points.
	/// </summary>
	public int ChannelPoints
	{
		get => _channelPoints.Value;
		set => _channelPoints.Value = value;
	}

	/// <summary>
	/// Candle series that feeds the bounce counter.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Provides read-only access to the accumulated bounce distribution.
	/// </summary>
	public IReadOnlyDictionary<int, int> BounceDistribution => _bounceDistribution;

	/// <summary>
	/// Initializes a new instance of the <see cref="BounceNumberStrategy"/> class.
	/// </summary>
	public BounceNumberStrategy()
	{
		_maxHistoryCandles = Param(nameof(MaxHistoryCandles), 10000)
			.SetNotNegative()
			.SetDisplay("Max History Candles", "Maximum number of candles inspected inside a single channel cycle", "General")
			;

		_channelPoints = Param(nameof(ChannelPoints), 10)
			.SetRange(10, 5000)
			.SetDisplay("Channel Half-Width", "Half height of the bounce channel measured in price points", "General")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used to perform the bounce analysis", "Data");
	}

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

		_bounceDistribution.Clear();
		_channelCenter = null;
		_bounceCount = 0;
		_lastTouchDirection = 0;
		_candlesInCycle = 0;
	}

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

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

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

		var channelHalf = GetChannelHalfWidth();

		if (channelHalf <= 0m)
			return;

		if (_channelCenter is null)
		{
			ResetChannel(candle.ClosePrice, channelHalf);
			return;
		}

		_candlesInCycle++;

		var center = _channelCenter.Value;
		var upperBand = center + channelHalf;
		var lowerBand = center - channelHalf;
		var breakUpper = center + channelHalf * 2m;
		var breakLower = center - channelHalf * 2m;

		var candleHigh = candle.HighPrice;
		var candleLow = candle.LowPrice;

		var breakoutUp = candleHigh >= breakUpper;
		var breakoutDown = candleLow <= breakLower;

		if (breakoutUp || breakoutDown || (_candlesInCycle >= MaxHistoryCandles && MaxHistoryCandles > 0))
		{
			RegisterBounceResult();
			ResetChannel(candle.ClosePrice, channelHalf);
			return;
		}

		var touchedLower = candleLow <= lowerBand && candleHigh >= lowerBand;
		var touchedUpper = candleHigh >= upperBand && candleLow <= upperBand;

		if (touchedLower && _lastTouchDirection >= 0)
		{
			_bounceCount++;
			_lastTouchDirection = -1;

			if (Position <= 0)
			{
				if (Position < 0)
					BuyMarket();
				BuyMarket();
			}
		}
		else if (touchedUpper && _lastTouchDirection <= 0)
		{
			_bounceCount++;
			_lastTouchDirection = 1;

			if (Position >= 0)
			{
				if (Position > 0)
					SellMarket();
				SellMarket();
			}
		}

		if (breakoutUp && Position <= 0)
		{
			if (Position < 0)
				BuyMarket();
			BuyMarket();
		}
		else if (breakoutDown && Position >= 0)
		{
			if (Position > 0)
				SellMarket();
			SellMarket();
		}
	}

	private void RegisterBounceResult()
	{
		if (!_bounceDistribution.TryGetValue(_bounceCount, out var occurrences))
			occurrences = 0;

		_bounceDistribution[_bounceCount] = occurrences + 1;

		LogInfo($"Channel cycle finished with {_bounceCount} bounce(s). Total occurrences for this count: {_bounceDistribution[_bounceCount]}.");
	}

	private void ResetChannel(decimal center, decimal channelHalf)
	{
		_channelCenter = center;
		_bounceCount = 0;
		_lastTouchDirection = 0;
		_candlesInCycle = 0;

		LogInfo($"Channel reset around price {center} with half-width {channelHalf}.");
	}

	private decimal GetChannelHalfWidth()
	{
		var priceStep = Security?.PriceStep;

		if (priceStep is null || priceStep.Value <= 0m)
			return ChannelPoints;

		return ChannelPoints * priceStep.Value;
	}
}