Bounce Number Strategy — это порт MetaTrader-индикатора BounceNumber_V0.mq4 / BounceNumber_V1.mq4 на платформу StockSharp. Оригинальный инструмент рисовал на графике таблицу, показывающую, сколько раз цена отскакивала внутри симметричного канала перед тем, как пробить его. В варианте на C# логика пересчёта реализована через высокоуровневый API StockSharp: стратегия подписывается на свечи, отслеживает касания верхней и нижней границ и ведёт распределение количества отскоков по каждому завершённому циклу.
В отличие от визуального индикатора MetaTrader, порт работает как компонент стратегии. Статистика доступна через свойство BounceDistribution и дублируется в информационном логе. Это позволяет подключать к стратегии внешние интерфейсы или системы отчётности и использовать собранные данные без привязки к конкретному терминалу.
Алгоритм работы
При старте стратегия проверяет, что у инструмента задан шаг цены (PriceStep). Параметр ChannelPoints интерпретируется в "пунктах" и требует корректной метаданные инструмента.
Подписка SubscribeCandles создаётся на тип свечей из параметра CandleType. Обработчик получает только полностью сформированные свечи (CandleStates.Finished).
Первая свеча задаёт центр канала (её цена закрытия). Полуширина канала равна ChannelPoints * PriceStep — полностью аналогично оригинальному коду на MQL.
Каждая новая свеча увеличивает счётчик бара в текущем цикле и проходит три проверки:
Пробой: если диапазон свечи достигает уровней центр ± 2 * полуширина, цикл завершается и текущий счётчик отскоков записывается в распределение.
Отскок от нижней границы: если свеча пересекает нижнюю границу и предыдущим событием был не нижний отскок, счётчик увеличивается, а направление последнего касания становится "низ".
Отскок от верхней границы: зеркальное правило для верхней границы.
Если цикл длится дольше, чем MaxHistoryCandles (при положительном значении параметра), выполняется принудительный сброс. Это защищает от ситуаций, когда цена долго дрейфует внутри канала и распределение не обновляется.
При каждом сбросе стратегия пишет в лог сообщение с количеством отскоков и обновляет словарь BounceDistribution, который можно прочитать из пользовательского интерфейса или кода.
Стратегия умышленно не выставляет заявки — её назначение аналитическое. Чтобы повторить внешний вид оригинального индикатора, можно привязать к стратегии пользовательский виджет, читающий распределение и отображающий его в виде таблицы или гистограммы.
Параметры
Имя
Тип
Значение по умолчанию
Аналог в MQL
Описание
MaxHistoryCandles
int
10000
maxbar
Максимальное число свечей в одном цикле до принудительного сброса. Значение 0 отключает ограничение.
ChannelPoints
int
300
BPoints
Полуширина канала в пунктах (множитель PriceStep).
CandleType
DataType
таймфрейм M1
TF
Тип свечей, используемый для расчётов.
Отличия от версии на MetaTrader
Вместо текстовых объектов на графике используется словарь Dictionary<int, int>, куда записывается гистограмма отскоков. Это упрощает экспорт данных и их последующую визуализацию.
Параметры, связанные с оформлением (цвета, шрифты, кнопка «Start»), удалены — они не влияли на расчётную часть.
Ограничение MaxHistoryCandles стало опциональным и работает как для исторических данных, так и в реальном времени.
Все сообщения и комментарии в коде переведены на английский язык в соответствии с требованиями репозитория.
Рекомендации по использованию
Перед запуском убедитесь, что у инструмента корректно заданы PriceStep и другие биржевые метаданные — без этого стратегия не сможет перевести пункты в денежные величины.
Используйте меньшие значения ChannelPoints для высокочастотных или низковолатильных инструментов и увеличивайте параметр на долгосрочных таймфреймах.
Чтобы воспроизвести режим пакетного расчёта, как в MQL, включите историю в коннекторе (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;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
class bounce_number_strategy(Strategy):
def __init__(self):
super(bounce_number_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5)))
self._max_history_candles = self.Param("MaxHistoryCandles", 10000)
self._channel_points = self.Param("ChannelPoints", 10)
self._channel_center = None
self._bounce_count = 0
self._last_touch_direction = 0
self._candles_in_cycle = 0
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def MaxHistoryCandles(self):
return self._max_history_candles.Value
@MaxHistoryCandles.setter
def MaxHistoryCandles(self, value):
self._max_history_candles.Value = value
@property
def ChannelPoints(self):
return self._channel_points.Value
@ChannelPoints.setter
def ChannelPoints(self, value):
self._channel_points.Value = value
def OnReseted(self):
super(bounce_number_strategy, self).OnReseted()
self._channel_center = None
self._bounce_count = 0
self._last_touch_direction = 0
self._candles_in_cycle = 0
def OnStarted2(self, time):
super(bounce_number_strategy, self).OnStarted2(time)
self._channel_center = None
self._bounce_count = 0
self._last_touch_direction = 0
self._candles_in_cycle = 0
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self._process_candle).Start()
def _get_channel_half_width(self):
return float(self.ChannelPoints)
def _reset_channel(self, center):
self._channel_center = center
self._bounce_count = 0
self._last_touch_direction = 0
self._candles_in_cycle = 0
def _process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
channel_half = self._get_channel_half_width()
if channel_half <= 0:
return
close = float(candle.ClosePrice)
high = float(candle.HighPrice)
low = float(candle.LowPrice)
if self._channel_center is None:
self._reset_channel(close)
return
self._candles_in_cycle += 1
center = self._channel_center
upper_band = center + channel_half
lower_band = center - channel_half
break_upper = center + channel_half * 2.0
break_lower = center - channel_half * 2.0
breakout_up = high >= break_upper
breakout_down = low <= break_lower
max_hist = self.MaxHistoryCandles
if breakout_up or breakout_down or (self._candles_in_cycle >= max_hist and max_hist > 0):
self._reset_channel(close)
return
touched_lower = low <= lower_band and high >= lower_band
touched_upper = high >= upper_band and low <= upper_band
if touched_lower and self._last_touch_direction >= 0:
self._bounce_count += 1
self._last_touch_direction = -1
if self.Position <= 0:
if self.Position < 0:
self.BuyMarket()
self.BuyMarket()
elif touched_upper and self._last_touch_direction <= 0:
self._bounce_count += 1
self._last_touch_direction = 1
if self.Position >= 0:
if self.Position > 0:
self.SellMarket()
self.SellMarket()
if breakout_up and self.Position <= 0:
if self.Position < 0:
self.BuyMarket()
self.BuyMarket()
elif breakout_down and self.Position >= 0:
if self.Position > 0:
self.SellMarket()
self.SellMarket()
def CreateClone(self):
return bounce_number_strategy()