GitHub で見る

Bounce Number Strategy

Overview

The Bounce Number Strategy is a StockSharp port of the MetaTrader indicator BounceNumber_V0.mq4 / BounceNumber_V1.mq4. The original tool was a visual analyzer that counted how many times price touched a symmetric channel before breaking out of it. This C# strategy recreates the bounce counter with the high-level API, stores the results in a distribution table, and reports every completed cycle through the strategy log. The implementation stays faithful to the MetaTrader logic while adapting it to StockSharp's event-driven pipeline.

Unlike the original indicator, the port runs as a strategy component. It subscribes to finished candles, monitors band touches, and tracks how many alternating hits occur before price exits the channel by twice its half-width. The collected statistics can be consumed from the BounceDistribution property or from the generated log messages.

How it works

  1. When the strategy starts it validates that the instrument exposes a non-zero PriceStep. Point-based inputs rely on this value to convert MetaTrader "points" into decimal price distances.
  2. A candle subscription created from CandleType feeds the bounce analyzer with completed bars only.
  3. The first incoming candle defines the channel center (its close price). A symmetric band whose half-width equals ChannelPoints * PriceStep is created around that center.
  4. Every new finished candle increments the cycle counter and is evaluated with three rules:
    • Breakout detection: if the candle's range crosses center ± 2 * halfWidth, the current cycle ends and its bounce count is recorded.
    • Lower band touch: if the candle spans the lower band and the previous touch was not also a lower band touch, the bounce counter increases by one and direction switches to "lower".
    • Upper band touch: symmetric rule for the upper band.
  5. If a cycle lasts more candles than MaxHistoryCandles (and the parameter is positive) the channel is forcefully reset, ensuring the histogram is updated even when price drifts sideways forever.
  6. On every cycle reset the distribution dictionary is updated and an information log is produced, mirroring the behaviour of the original interface counters.

The strategy does not place any orders by design. It should be hosted alongside other components (dashboards, UI, data exporters) that consume the BounceDistribution statistics.

Parameters

Name Type Default MetaTrader analogue Description
MaxHistoryCandles int 10000 maxbar input Maximum number of candles allowed inside one cycle before a forced reset. Set to 0 to disable the safety reset.
ChannelPoints int 300 BPoints input Half-width of the bounce channel expressed in price points (PriceStep multiples).
CandleType DataType M1 timeframe TF input Candle series used for the bounce calculations.

Differences vs. MetaTrader code

  • The histogram is stored as a dictionary instead of on-chart text objects. This makes the information easier to export or visualize in StockSharp dashboards.
  • UI-specific inputs from the indicator (colours, fonts, buttons) are removed because they were cosmetic and have no impact on the analytical logic.
  • The forced reset by MaxHistoryCandles is now optional (0 disables it) and works on live data streams, whereas MetaTrader processed a finite historical block.
  • All informative messages are written in English through AddInfoLog, matching the requirement for English-only code comments/logs.

Usage tips

  • Ensure that the selected security defines PriceStep; otherwise, the strategy throws an exception on start because point-based offsets cannot be calculated.
  • Combine the strategy with custom UI widgets or scripts that read BounceDistribution to replicate the MetaTrader grid of counts.
  • Use smaller values for ChannelPoints when analysing intraday noise and larger values for higher timeframes or volatile instruments.
  • To emulate the historical scan from the MQL version, start the strategy with HistoryBuildMode enabled in your connector and let it process the requested historical range; the distribution will be populated as soon as the backfilled candles are delivered.
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;
	}
}