Statistics Repeating Behavior Strategy
Intraday strategy that studies how candles behaved at the same time of day during the last N trading sessions. For every new bar it compares the accumulated bullish and bearish body sizes from previous days. If bullish pressure dominates it opens a long position at the bar open, otherwise it goes short. Positions are closed on the next bar and a fixed pip stop loss mimics the original MetaTrader logic. Position size follows a golden-ratio martingale by growing after losses and resetting after wins.
Trading Logic
- At the start of each new candle, close any open position from the previous bar.
- Look up candles from the last
HistoryDaystrading days that opened at the same hour and minute. - Sum the candle bodies (in points) separately for bullish and bearish closes, ignoring bodies smaller than
MinimumBodyPoints. - If bullish sum exceeds bearish sum → open a long position using the current volume.
- If bearish sum exceeds bullish sum → open a short position.
- Apply a stop loss of
StopLossPipsconverted through the instrument price step. The stop is checked against intrabar extremes when the candle finishes. - When the trade closes:
- If the result is profitable, reset volume back to
InitialVolume. - Otherwise multiply the current volume by
MartingaleFactor(respecting volume step and limits).
- If the result is profitable, reset volume back to
Parameters
- HistoryDays (default: 10) — number of previous days to include in the statistics.
- MinimumBodyPoints (default: 10) — candles with a body smaller than this threshold (in points) are ignored.
- StopLossPips (default: 15) — pip distance of the protective stop.
- InitialVolume (default: 0.1) — starting order size before martingale adjustments.
- MartingaleFactor (default: 1.618) — multiplier applied after a losing trade.
- CandleType (default: 1 hour) — timeframe used for candles.
Trading Characteristics
- Market Side: Both long and short depending on statistics.
- Timeframe: Configurable (default hourly) with exact matching by hour and minute.
- Position Management: Single position at a time, closed on the next bar or when stop loss is hit.
- Risk: Uses fixed pip stop and martingale sizing, which can grow volume quickly after consecutive losses.
- Instruments: Works with instruments that provide a valid
MinPriceStepand volume limits.
Implementation Notes
- Candle bodies are stored per minute-of-day in a rolling queue capped by
HistoryDays. - Volumes are normalized to the instrument volume step and bounded by
MinVolume/MaxVolume. - Stop loss detection relies on completed candle extremes to emulate intrabar execution from the original MQL5 expert.
- All inline code comments are provided in English to align with repository requirements.
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>
/// Strategy that analyzes historical candle bodies for the same time of day.
/// Opens a position when bullish or bearish pressure dominates over recent days.
/// Implements simple martingale sizing after losing trades.
/// </summary>
public class StatisticsRepeatingBehaviorStrategy : Strategy
{
private readonly StrategyParam<int> _historyDays;
private readonly StrategyParam<int> _minimumBodyPoints;
private readonly StrategyParam<int> _stopLossPips;
private readonly StrategyParam<decimal> _initialVolume;
private readonly StrategyParam<decimal> _martingaleFactor;
private readonly StrategyParam<DataType> _candleType;
private readonly System.Collections.Concurrent.ConcurrentDictionary<int, BodyStatistics> _bodyStatistics = new();
private decimal _currentVolume;
private decimal _entryPrice;
private decimal _stopPrice;
private int _positionDirection;
private decimal _priceStep;
private TimeSpan _timeFrame;
/// <summary>
/// Number of historical days to aggregate for statistics.
/// </summary>
public int HistoryDays
{
get => _historyDays.Value;
set => _historyDays.Value = value;
}
/// <summary>
/// Minimum body size in points for a candle to contribute into the statistics.
/// </summary>
public int MinimumBodyPoints
{
get => _minimumBodyPoints.Value;
set => _minimumBodyPoints.Value = value;
}
/// <summary>
/// Stop loss distance measured in pips.
/// </summary>
public int StopLossPips
{
get => _stopLossPips.Value;
set => _stopLossPips.Value = value;
}
/// <summary>
/// Initial order size used before applying martingale adjustments.
/// </summary>
public decimal InitialVolume
{
get => _initialVolume.Value;
set => _initialVolume.Value = value;
}
/// <summary>
/// Multiplier applied to the order size after a losing trade.
/// </summary>
public decimal MartingaleFactor
{
get => _martingaleFactor.Value;
set => _martingaleFactor.Value = value;
}
/// <summary>
/// Candle type to analyze.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Initializes a new instance of <see cref="StatisticsRepeatingBehaviorStrategy"/>.
/// </summary>
public StatisticsRepeatingBehaviorStrategy()
{
_historyDays = Param(nameof(HistoryDays), 3)
.SetGreaterThanZero()
.SetDisplay("History Days", "Number of days to collect statistics", "Parameters")
;
_minimumBodyPoints = Param(nameof(MinimumBodyPoints), 0)
.SetDisplay("Minimum Body (points)", "Ignore candles with smaller body", "Parameters")
;
_stopLossPips = Param(nameof(StopLossPips), 15)
.SetGreaterThanZero()
.SetDisplay("Stop Loss (pips)", "Stop loss distance in pips", "Risk")
;
_initialVolume = Param(nameof(InitialVolume), 1m)
.SetGreaterThanZero()
.SetDisplay("Initial Volume", "Starting order size", "Trading")
;
_martingaleFactor = Param(nameof(MartingaleFactor), 1.618m)
.SetGreaterThanZero()
.SetDisplay("Martingale Factor", "Multiplier after losing trade", "Trading")
;
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
.SetDisplay("Candle Type", "Candles for analysis", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_bodyStatistics.Clear();
_currentVolume = 0m;
_entryPrice = 0m;
_stopPrice = 0m;
_positionDirection = 0;
_priceStep = 0m;
_timeFrame = TimeSpan.Zero;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_priceStep = Security.PriceStep ?? 1m;
_timeFrame = CandleType.Arg is TimeSpan span ? span : TimeSpan.Zero;
if (_timeFrame <= TimeSpan.Zero)
_timeFrame = TimeSpan.FromMinutes(1);
_currentVolume = AdjustVolume(InitialVolume);
var subscription = SubscribeCandles(CandleType);
subscription
.Bind(ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var nextOpen = candle.OpenTime + _timeFrame;
var nextKey = GetMinuteKey(nextOpen);
// Close existing position at the beginning of the new bar.
if (_positionDirection != 0)
{
var exitPrice = candle.ClosePrice;
var stopHit = false;
if (_positionDirection > 0)
{
if (candle.LowPrice <= _stopPrice)
{
exitPrice = _stopPrice;
stopHit = true;
}
}
else
{
if (candle.HighPrice >= _stopPrice)
{
exitPrice = _stopPrice;
stopHit = true;
}
}
if (Position > 0)
SellMarket(Math.Abs(Position));
else if (Position < 0)
BuyMarket(Math.Abs(Position));
UpdateVolumeAfterTrade(exitPrice, stopHit);
}
if (_positionDirection == 0 && _bodyStatistics.TryGetValue(nextKey, out var stats) && stats.Count > 0)
{
var bullSum = stats.BullSum;
var bearSum = stats.BearSum;
if (bullSum > bearSum && Position <= 0)
{
EnterPosition(candle, true);
}
else if (bearSum > bullSum && Position >= 0)
{
EnterPosition(candle, false);
}
}
UpdateStatistics(candle);
}
private void EnterPosition(ICandleMessage candle, bool isLong)
{
var volume = _currentVolume;
if (volume <= 0m)
return;
var stopDistance = StopLossPips * _priceStep;
if (stopDistance <= 0m)
stopDistance = _priceStep;
if (isLong)
{
BuyMarket(volume);
_entryPrice = candle.ClosePrice;
_stopPrice = _entryPrice - stopDistance;
_positionDirection = 1;
}
else
{
SellMarket(volume);
_entryPrice = candle.ClosePrice;
_stopPrice = _entryPrice + stopDistance;
_positionDirection = -1;
}
}
private void UpdateVolumeAfterTrade(decimal exitPrice, bool stopHit)
{
if (_positionDirection == 0)
return;
var profit = (_positionDirection > 0 ? exitPrice - _entryPrice : _entryPrice - exitPrice);
if (profit > 0m && !stopHit)
{
_currentVolume = AdjustVolume(InitialVolume);
}
else
{
var current = _currentVolume > 0m ? _currentVolume : InitialVolume;
var increased = AdjustVolume(current * MartingaleFactor);
_currentVolume = increased;
}
_entryPrice = 0m;
_stopPrice = 0m;
_positionDirection = 0;
}
private void UpdateStatistics(ICandleMessage candle)
{
var currentKey = GetMinuteKey(candle.OpenTime);
var stats = _bodyStatistics.GetOrAdd(currentKey, _ => new BodyStatistics());
var body = candle.ClosePrice - candle.OpenPrice;
var bodyPoints = body / _priceStep;
var absBody = Math.Abs(bodyPoints);
if (MinimumBodyPoints > 0 && absBody < MinimumBodyPoints)
return;
stats.Enqueue(bodyPoints);
while (stats.Count > HistoryDays)
{
var removed = stats.Dequeue();
if (removed > 0m)
stats.BullSum -= removed;
else if (removed < 0m)
stats.BearSum -= Math.Abs(removed);
}
}
private decimal AdjustVolume(decimal volume)
{
if (volume <= 0m)
return 0m;
var security = Security;
if (security == null)
return volume;
var min = security.MinVolume ?? 0m;
var max = security.MaxVolume is > 0m ? security.MaxVolume.Value : decimal.MaxValue;
if (max < min)
return 0m;
var step = security.VolumeStep ?? 0m;
if (step <= 0m)
return Math.Clamp(volume, min, max);
var first = min > 0m ? Math.Ceiling(min / step) * step : step;
var last = max == decimal.MaxValue ? decimal.MaxValue : Math.Floor(max / step) * step;
if (last < first)
return 0m;
var aligned = Math.Floor(volume / step) * step;
return Math.Clamp(aligned, first, last);
}
private static int GetMinuteKey(DateTimeOffset time)
{
return time.Hour * 60 + time.Minute;
}
private sealed class BodyStatistics
{
private readonly Queue<decimal> _values = new();
public decimal BullSum { get; set; }
public decimal BearSum { get; set; }
public int Count => _values.Count;
public void Enqueue(decimal value)
{
_values.Enqueue(value);
if (value > 0m)
BullSum += value;
else if (value < 0m)
BearSum += Math.Abs(value);
}
public decimal Dequeue()
{
return _values.Dequeue();
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import Math, TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
from datatype_extensions import *
from indicator_extensions import *
from collections import deque
class statistics_repeating_behavior_strategy(Strategy):
"""Candle body statistics by time-of-day with martingale sizing."""
def __init__(self):
super(statistics_repeating_behavior_strategy, self).__init__()
self._history_days = self.Param("HistoryDays", 3).SetGreaterThanZero().SetDisplay("History Days", "Number of days to collect statistics", "Parameters")
self._minimum_body_points = self.Param("MinimumBodyPoints", 0).SetDisplay("Minimum Body (points)", "Ignore candles with smaller body", "Parameters")
self._stop_loss_pips = self.Param("StopLossPips", 15).SetGreaterThanZero().SetDisplay("Stop Loss (pips)", "Stop loss distance in pips", "Risk")
self._initial_volume = self.Param("InitialVolume", 1.0).SetGreaterThanZero().SetDisplay("Initial Volume", "Starting order size", "Trading")
self._martingale_factor = self.Param("MartingaleFactor", 1.618).SetGreaterThanZero().SetDisplay("Martingale Factor", "Multiplier after losing trade", "Trading")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(4))).SetDisplay("Candle Type", "Candles for analysis", "General")
@property
def CandleType(self): return self._candle_type.Value
@CandleType.setter
def CandleType(self, value): self._candle_type.Value = value
def OnReseted(self):
super(statistics_repeating_behavior_strategy, self).OnReseted()
self._body_stats = {}
self._entry_price = 0
self._stop_price = 0
self._pos_dir = 0
self._current_volume = 0.0
self._price_step = 0.0
self._timeframe_minutes = 0
def OnStarted2(self, time):
super(statistics_repeating_behavior_strategy, self).OnStarted2(time)
self._body_stats = {}
self._entry_price = 0
self._stop_price = 0
self._pos_dir = 0
self._current_volume = self._adjust_volume(float(self._initial_volume.Value))
self._price_step = float(self.Security.PriceStep) if self.Security.PriceStep is not None else 1.0
ct = self.CandleType
arg = ct.Arg
if hasattr(arg, 'TotalMinutes'):
self._timeframe_minutes = int(arg.TotalMinutes)
else:
self._timeframe_minutes = 1
sub = self.SubscribeCandles(self.CandleType)
sub.Bind(self.OnProcess).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, sub)
self.DrawOwnTrades(area)
def OnProcess(self, candle):
if candle.State != CandleStates.Finished:
return
open_time = candle.OpenTime
next_key = self._get_minute_key_offset(open_time, self._timeframe_minutes)
close = float(candle.ClosePrice)
high = float(candle.HighPrice)
low = float(candle.LowPrice)
open_p = float(candle.OpenPrice)
stop_distance = float(self._stop_loss_pips.Value) * self._price_step
# Close existing position
if self._pos_dir != 0:
exit_price = close
stop_hit = False
if self._pos_dir > 0:
if low <= self._stop_price:
exit_price = self._stop_price
stop_hit = True
else:
if high >= self._stop_price:
exit_price = self._stop_price
stop_hit = True
if self.Position > 0:
self.SellMarket(abs(self.Position))
elif self.Position < 0:
self.BuyMarket(abs(self.Position))
profit = (exit_price - self._entry_price) if self._pos_dir > 0 else (self._entry_price - exit_price)
if profit > 0 and not stop_hit:
self._current_volume = self._adjust_volume(float(self._initial_volume.Value))
else:
current = self._current_volume if self._current_volume > 0 else float(self._initial_volume.Value)
self._current_volume = self._adjust_volume(current * float(self._martingale_factor.Value))
self._entry_price = 0
self._stop_price = 0
self._pos_dir = 0
# Entry based on statistics
if self._pos_dir == 0 and next_key in self._body_stats:
stats = self._body_stats[next_key]
if len(stats['values']) > 0:
bull_sum = stats['bull_sum']
bear_sum = stats['bear_sum']
volume = self._current_volume
if volume > 0:
if bull_sum > bear_sum and self.Position <= 0:
self._entry_price = close
self._stop_price = close - stop_distance
self._pos_dir = 1
self.BuyMarket(volume)
elif bear_sum > bull_sum and self.Position >= 0:
self._entry_price = close
self._stop_price = close + stop_distance
self._pos_dir = -1
self.SellMarket(volume)
# Update statistics
current_key = open_time.Hour * 60 + open_time.Minute
if current_key not in self._body_stats:
self._body_stats[current_key] = {'values': deque(), 'bull_sum': 0, 'bear_sum': 0}
stats = self._body_stats[current_key]
body = close - open_p
body_points = body / self._price_step
min_body = self._minimum_body_points.Value
abs_body = abs(body_points)
if min_body > 0 and abs_body < min_body:
return
stats['values'].append(body_points)
if body_points > 0:
stats['bull_sum'] += body_points
elif body_points < 0:
stats['bear_sum'] += abs(body_points)
while len(stats['values']) > self._history_days.Value:
removed = stats['values'].popleft()
if removed > 0:
stats['bull_sum'] -= removed
elif removed < 0:
stats['bear_sum'] -= abs(removed)
def _get_minute_key_offset(self, dt, offset_minutes):
total = dt.Hour * 60 + dt.Minute + offset_minutes
return total % 1440
def _adjust_volume(self, volume):
if volume <= 0:
return 0.0
security = self.Security
if security is None:
return volume
minimum = float(security.MinVolume) if security.MinVolume is not None else 0.0
maximum = float(security.MaxVolume) if security.MaxVolume is not None and security.MaxVolume > 0 else float("inf")
if maximum < minimum:
return 0.0
step = float(security.VolumeStep) if security.VolumeStep is not None else 0.0
if step <= 0:
return max(minimum, min(volume, maximum))
first = Math.Ceiling(minimum / step) * step if minimum > 0 else step
last = Math.Floor(maximum / step) * step if maximum != float("inf") else float("inf")
if last < first:
return 0.0
aligned = Math.Floor(volume / step) * step
return round(max(first, min(aligned, last)), 12)
def CreateClone(self):
return statistics_repeating_behavior_strategy()