Bull Row Breakout 策略
概览
Bull Row Breakout 策略是 MetaTrader 5 专家顾问 “BULL row full EA” 的 C# 版本。原策略使用模块化构建,结合蜡烛排列和动量确认。移植到 StockSharp 后,我们在单一可配置的时间框架上复现相同逻辑,并按照仓库要求将代码注释保持为英文。
当一组看跌蜡烛被看涨动量和向上突破取代时,策略只做多头。Stochastic 随机指标用于过滤动量,而动态止损与止盈重建了 MQL 版本的风险设置。
入场流程
- 仅在新蜡烛收盘时评估信号(“每根柱子一次”)。
- 如果当前没有多头仓位,继续检测条件。
- 看跌排列:
- 从
BearShift开始向前数BearRowSize根蜡烛必须全部收阴。 - 每根蜡烛实体至少达到
BearMinBody个价格步长。 - 实体变化需符合
BearRowMode(普通 / 逐渐增大 / 逐渐减小)。
- 从
- 看涨排列:
- 从
BullShift开始向前数BullRowSize根蜡烛必须全部收阳。 - 每根蜡烛实体至少达到
BullMinBody个价格步长。 - 实体变化需符合
BullRowMode。
- 从
- 突破确认:最近收盘价要高于第 2 根到第
BreakoutLookback根历史蜡烛的最高价。 - 随机指标确认:
- 当前 %K(
StochasticKPeriod)必须高于 %D(StochasticDPeriod)。 - 过去
StochasticRangePeriod个 %K 值全部位于StochasticLowerLevel与StochasticUpperLevel之间。
- 当前 %K(
- 风险控制:
- 止损价格取自最近
StopLossLookback根蜡烛的最低价。 - 止盈价格等于止损距离的
TakeProfitPercent%。 - 每根蜡烛收盘时检测是否触发止损或止盈,若触发则用
SellMarket平仓。
- 止损价格取自最近
参数说明
| 参数 | 说明 |
|---|---|
Volume |
每次入场使用的固定交易量。 |
CandleTimeFrame |
参与计算的蜡烛时间框架。 |
StopLossLookback |
计算动态止损所使用的历史蜡烛数量。 |
TakeProfitPercent |
止盈相对于止损距离的百分比。 |
BearRowSize、BearMinBody、BearRowMode、BearShift |
看跌排列设置。 |
BullRowSize、BullMinBody、BullRowMode、BullShift |
看涨排列设置。 |
BreakoutLookback |
用于突破确认的最高价回溯长度。 |
StochasticKPeriod、StochasticDPeriod、StochasticSlowing |
随机指标参数。 |
StochasticRangePeriod |
需要保持在通道内的 %K 历史值数量。 |
StochasticUpperLevel、StochasticLowerLevel |
%K 通道上下界。 |
蜡烛实体长度以价格步长表示,对应原版中 toDigits 的处理方式。当品种没有提供价格步长时,默认使用 1。
与 MQL 版本的差异
- 原策略可以为每个模块选择不同的时间框架,此移植版在单一的
CandleTimeFrame上运行,以匹配最常见的使用方式。 - 模块化代码中的虚拟止损和挂单管理未在移植版中实现。
- 止损与止盈通过检测蜡烛实现,一旦价格越过水平即调用
SellMarket平仓。 - 原有的图形对象与状态显示未移植。
使用建议
- 根据交易品种优化蜡烛序列的长度与偏移;默认值复现了原策略的设定(向前 3 根看跌 + 向前 2 根看涨)。
- 可通过调整
StochasticLowerLevel和StochasticUpperLevel改变过滤强度。 - 由于止损依赖最近低点,出现跳空的市场可能需要增大
StopLossLookback或增加额外过滤条件。
namespace StockSharp.Samples.Strategies;
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;
using StockSharp.Algo;
/// <summary>
/// Strategy converted from the "BULL row full EA" expert advisor.
/// </summary>
public class BullRowBreakoutStrategy : Strategy
{
private readonly List<ICandleMessage> _candles = new();
private readonly Queue<decimal> _stochasticHistory = new();
private StochasticOscillator _stochastic = null!;
private decimal? _stopPrice;
private decimal? _takeProfitPrice;
private readonly StrategyParam<TimeSpan> _candleTimeFrame;
private readonly StrategyParam<int> _stopLossLookback;
private readonly StrategyParam<decimal> _takeProfitPercent;
private readonly StrategyParam<int> _bearRowSize;
private readonly StrategyParam<decimal> _bearMinBody;
private readonly StrategyParam<RowSequenceModes> _bearRowMode;
private readonly StrategyParam<int> _bearShift;
private readonly StrategyParam<int> _bullRowSize;
private readonly StrategyParam<decimal> _bullMinBody;
private readonly StrategyParam<RowSequenceModes> _bullRowMode;
private readonly StrategyParam<int> _bullShift;
private readonly StrategyParam<int> _breakoutLookback;
private readonly StrategyParam<int> _stochasticKPeriod;
private readonly StrategyParam<int> _stochasticDPeriod;
private readonly StrategyParam<int> _stochasticSlowing;
private readonly StrategyParam<int> _stochasticRangePeriod;
private readonly StrategyParam<decimal> _stochasticUpperLevel;
private readonly StrategyParam<decimal> _stochasticLowerLevel;
/// <summary>
/// Initializes a new instance of the <see cref="BullRowBreakoutStrategy"/> class.
/// </summary>
public BullRowBreakoutStrategy()
{
_candleTimeFrame = Param(nameof(CandleTimeFrame), TimeSpan.FromMinutes(5))
.SetDisplay("Timeframe", "Primary candle timeframe", "Market")
;
_stopLossLookback = Param(nameof(StopLossLookback), 10)
.SetDisplay("Stop loss bars", "Bars used to locate protective stop", "Risk")
;
_takeProfitPercent = Param(nameof(TakeProfitPercent), 100m)
.SetDisplay("Take profit %", "Reward distance relative to stop", "Risk")
;
_bearRowSize = Param(nameof(BearRowSize), 3)
.SetDisplay("Bear row size", "Required consecutive bearish candles", "Pattern")
;
_bearMinBody = Param(nameof(BearMinBody), 0m)
.SetDisplay("Bear min body", "Minimum bearish candle body (price steps)", "Pattern")
;
_bearRowMode = Param(nameof(BearRowMode), RowSequenceModes.Normal)
.SetDisplay("Bear row mode", "Body size progression for bearish row", "Pattern")
;
_bearShift = Param(nameof(BearShift), 3)
.SetDisplay("Bear row shift", "How many bars back the bearish row starts", "Pattern")
;
_bullRowSize = Param(nameof(BullRowSize), 2)
.SetDisplay("Bull row size", "Required consecutive bullish candles", "Pattern")
;
_bullMinBody = Param(nameof(BullMinBody), 0m)
.SetDisplay("Bull min body", "Minimum bullish candle body (price steps)", "Pattern")
;
_bullRowMode = Param(nameof(BullRowMode), RowSequenceModes.Normal)
.SetDisplay("Bull row mode", "Body size progression for bullish row", "Pattern")
;
_bullShift = Param(nameof(BullShift), 1)
.SetDisplay("Bull row shift", "How many bars back the bullish row starts", "Pattern")
;
_breakoutLookback = Param(nameof(BreakoutLookback), 10)
.SetDisplay("Breakout lookback", "Bars checked for the breakout filter", "Pattern")
;
_stochasticKPeriod = Param(nameof(StochasticKPeriod), 40)
.SetDisplay("Stochastic %K", "%K period", "Indicators")
;
_stochasticDPeriod = Param(nameof(StochasticDPeriod), 8)
.SetDisplay("Stochastic %D", "%D period", "Indicators")
;
_stochasticSlowing = Param(nameof(StochasticSlowing), 10)
.SetDisplay("Stochastic slowing", "Smoothing applied to %K", "Indicators")
;
_stochasticRangePeriod = Param(nameof(StochasticRangePeriod), 3)
.SetDisplay("Stochastic bars", "Bars that must remain inside the oscillator channel", "Indicators")
;
_stochasticUpperLevel = Param(nameof(StochasticUpperLevel), 70m)
.SetDisplay("Stochastic upper", "Upper bound for the oscillator", "Indicators")
;
_stochasticLowerLevel = Param(nameof(StochasticLowerLevel), 30m)
.SetDisplay("Stochastic lower", "Lower bound for the oscillator", "Indicators")
;
}
/// <summary>
/// Primary candle timeframe.
/// </summary>
public TimeSpan CandleTimeFrame
{
get => _candleTimeFrame.Value;
set => _candleTimeFrame.Value = value;
}
/// <summary>
/// Bars used to locate the stop price.
/// </summary>
public int StopLossLookback
{
get => _stopLossLookback.Value;
set => _stopLossLookback.Value = value;
}
/// <summary>
/// Take profit distance relative to the stop in percent.
/// </summary>
public decimal TakeProfitPercent
{
get => _takeProfitPercent.Value;
set => _takeProfitPercent.Value = value;
}
/// <summary>
/// Bearish row length in candles.
/// </summary>
public int BearRowSize
{
get => _bearRowSize.Value;
set => _bearRowSize.Value = value;
}
/// <summary>
/// Minimum bearish body expressed in price steps.
/// </summary>
public decimal BearMinBody
{
get => _bearMinBody.Value;
set => _bearMinBody.Value = value;
}
/// <summary>
/// Bearish row body progression requirement.
/// </summary>
public RowSequenceModes BearRowMode
{
get => _bearRowMode.Value;
set => _bearRowMode.Value = value;
}
/// <summary>
/// Offset in bars where the bearish row starts.
/// </summary>
public int BearShift
{
get => _bearShift.Value;
set => _bearShift.Value = value;
}
/// <summary>
/// Bullish row length in candles.
/// </summary>
public int BullRowSize
{
get => _bullRowSize.Value;
set => _bullRowSize.Value = value;
}
/// <summary>
/// Minimum bullish body expressed in price steps.
/// </summary>
public decimal BullMinBody
{
get => _bullMinBody.Value;
set => _bullMinBody.Value = value;
}
/// <summary>
/// Bullish row body progression requirement.
/// </summary>
public RowSequenceModes BullRowMode
{
get => _bullRowMode.Value;
set => _bullRowMode.Value = value;
}
/// <summary>
/// Offset in bars where the bullish row starts.
/// </summary>
public int BullShift
{
get => _bullShift.Value;
set => _bullShift.Value = value;
}
/// <summary>
/// Lookback used to determine the breakout high.
/// </summary>
public int BreakoutLookback
{
get => _breakoutLookback.Value;
set => _breakoutLookback.Value = value;
}
/// <summary>
/// Stochastic %K length.
/// </summary>
public int StochasticKPeriod
{
get => _stochasticKPeriod.Value;
set => _stochasticKPeriod.Value = value;
}
/// <summary>
/// Stochastic %D length.
/// </summary>
public int StochasticDPeriod
{
get => _stochasticDPeriod.Value;
set => _stochasticDPeriod.Value = value;
}
/// <summary>
/// Additional smoothing applied to %K.
/// </summary>
public int StochasticSlowing
{
get => _stochasticSlowing.Value;
set => _stochasticSlowing.Value = value;
}
/// <summary>
/// Number of candles that must remain inside the Stochastic channel.
/// </summary>
public int StochasticRangePeriod
{
get => _stochasticRangePeriod.Value;
set => _stochasticRangePeriod.Value = value;
}
/// <summary>
/// Upper bound for the Stochastic filter.
/// </summary>
public decimal StochasticUpperLevel
{
get => _stochasticUpperLevel.Value;
set => _stochasticUpperLevel.Value = value;
}
/// <summary>
/// Lower bound for the Stochastic filter.
/// </summary>
public decimal StochasticLowerLevel
{
get => _stochasticLowerLevel.Value;
set => _stochasticLowerLevel.Value = value;
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_candles.Clear();
_stochasticHistory.Clear();
_stopPrice = null;
_takeProfitPrice = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_candles.Clear();
_stochasticHistory.Clear();
_stopPrice = null;
_takeProfitPrice = null;
_stochastic = new StochasticOscillator
{
K = { Length = StochasticKPeriod },
D = { Length = StochasticDPeriod },
};
var subscription = SubscribeCandles(CandleTimeFrame.TimeFrame());
subscription
.BindEx(_stochastic, ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, _stochastic);
DrawOwnTrades(area);
}
StartProtection(null, null);
}
private void ProcessCandle(ICandleMessage candle, IIndicatorValue stochasticValue)
{
if (candle.State != CandleStates.Finished)
return;
var stoch = (StochasticOscillatorValue)stochasticValue;
if (!stochasticValue.IsFinal || stoch.K is not decimal kValue || stoch.D is not decimal dValue)
return;
_candles.Add(candle);
var maxNeeded = Math.Max(Math.Max(BearShift + BearRowSize - 1, BullShift + BullRowSize - 1), Math.Max(StopLossLookback, BreakoutLookback));
if (_candles.Count > Math.Max(maxNeeded + 5, StochasticRangePeriod + 5))
_candles.RemoveAt(0);
_stochasticHistory.Enqueue(kValue);
while (_stochasticHistory.Count > Math.Max(StochasticRangePeriod, 1))
_stochasticHistory.Dequeue();
ManageProtectiveLevels(candle);
if (Position > 0m)
return;
if (!HasEnoughHistory())
return;
if (!HasBearRow())
return;
if (!HasBullRow())
return;
if (!HasBreakout())
return;
if (!HasStochasticCross(kValue, dValue))
return;
if (!IsStochasticContained())
return;
var volume = Volume;
if (volume <= 0m)
return;
var stopPrice = CalculateStopPrice();
if (stopPrice is null)
return;
var entryPrice = candle.ClosePrice;
var risk = entryPrice - stopPrice.Value;
if (risk <= 0m)
return;
var reward = risk * TakeProfitPercent / 100m;
var takeProfitPrice = entryPrice + reward;
if (BuyMarket(volume) is null)
return;
_stopPrice = stopPrice;
_takeProfitPrice = takeProfitPrice;
}
private void ManageProtectiveLevels(ICandleMessage candle)
{
if (Position <= 0m)
return;
if (_stopPrice is decimal stop && candle.LowPrice <= stop)
{
SellMarket(Math.Abs(Position));
ResetProtection();
return;
}
if (_takeProfitPrice is decimal take && candle.HighPrice >= take)
{
SellMarket(Math.Abs(Position));
ResetProtection();
return;
}
}
private void ResetProtection()
{
_stopPrice = null;
_takeProfitPrice = null;
}
private bool HasEnoughHistory()
{
if (_candles.Count < Math.Max(BreakoutLookback, StopLossLookback))
return false;
var bearRequirement = BearShift + BearRowSize - 1;
var bullRequirement = BullShift + BullRowSize - 1;
var minCandles = Math.Max(bearRequirement, bullRequirement);
return _candles.Count >= Math.Max(minCandles, 2);
}
private bool HasBearRow() => HasRow(BearRowSize, BearMinBody, BearRowMode, BearShift, isBullish: false);
private bool HasBullRow() => HasRow(BullRowSize, BullMinBody, BullRowMode, BullShift, isBullish: true);
private bool HasRow(int size, decimal minBody, RowSequenceModes mode, int shift, bool isBullish)
{
if (size <= 0 || shift <= 0)
return false;
var maxShift = shift + size - 1;
if (_candles.Count < maxShift)
return false;
var bodyStep = Security?.PriceStep ?? 0m;
if (bodyStep <= 0m)
bodyStep = 1m;
var minBodyValue = minBody * bodyStep;
decimal previousBody = 0m;
for (var i = 0; i < size; i++)
{
var candle = GetCandle(shift + i);
var body = isBullish ? candle.ClosePrice - candle.OpenPrice : candle.OpenPrice - candle.ClosePrice;
if (body <= 0m)
return false;
if (body < minBodyValue)
return false;
if (mode == RowSequenceModes.Bigger && previousBody > 0m && body <= previousBody)
return false;
if (mode == RowSequenceModes.Smaller && previousBody > 0m && body >= previousBody)
return false;
previousBody = body;
}
return true;
}
private bool HasBreakout()
{
if (BreakoutLookback <= 2)
return false;
var prevClose = GetCandle(1).ClosePrice;
var highest = decimal.MinValue;
for (var shift = 2; shift <= BreakoutLookback; shift++)
{
var candle = GetCandle(shift);
highest = Math.Max(highest, candle.HighPrice);
}
return prevClose > highest;
}
private bool HasStochasticCross(decimal kValue, decimal dValue)
{
return kValue > dValue;
}
private bool IsStochasticContained()
{
if (StochasticRangePeriod <= 0)
return true;
if (_stochasticHistory.Count < StochasticRangePeriod)
return false;
var history = _stochasticHistory.ToArray();
return history.All(v => v <= StochasticUpperLevel && v >= StochasticLowerLevel);
}
private decimal? CalculateStopPrice()
{
if (StopLossLookback <= 0)
return null;
var lowest = decimal.MaxValue;
var bars = Math.Min(StopLossLookback, _candles.Count);
for (var shift = 1; shift <= bars; shift++)
{
var candle = GetCandle(shift);
lowest = Math.Min(lowest, candle.LowPrice);
}
return lowest == decimal.MaxValue ? null : lowest;
}
private ICandleMessage GetCandle(int shift)
{
return _candles[^shift];
}
public enum RowSequenceModes
{
/// <summary>
/// Only direction and minimum body size are checked.
/// </summary>
Normal,
/// <summary>
/// Each candle must have a larger body than the previous one.
/// </summary>
Bigger,
/// <summary>
/// Each candle must have a smaller body than the previous one.
/// </summary>
Smaller
}
}
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.Indicators import ExponentialMovingAverage
from StockSharp.Algo.Strategies import Strategy
class bull_row_breakout_strategy(Strategy):
def __init__(self):
super(bull_row_breakout_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5)))
self._ema_period = self.Param("EmaPeriod", 20)
self._bull_row_size = self.Param("BullRowSize", 2)
self._breakout_lookback = self.Param("BreakoutLookback", 10)
self._stop_loss_pct = self.Param("StopLossPct", 2.0)
self._take_profit_pct = self.Param("TakeProfitPct", 3.0)
self._candles = []
self._entry_price = 0.0
self._has_prev = False
self._prev_close = 0.0
self._prev_ema = 0.0
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def EmaPeriod(self):
return self._ema_period.Value
@EmaPeriod.setter
def EmaPeriod(self, value):
self._ema_period.Value = value
@property
def BullRowSize(self):
return self._bull_row_size.Value
@BullRowSize.setter
def BullRowSize(self, value):
self._bull_row_size.Value = value
@property
def BreakoutLookback(self):
return self._breakout_lookback.Value
@BreakoutLookback.setter
def BreakoutLookback(self, value):
self._breakout_lookback.Value = value
@property
def StopLossPct(self):
return self._stop_loss_pct.Value
@StopLossPct.setter
def StopLossPct(self, value):
self._stop_loss_pct.Value = value
@property
def TakeProfitPct(self):
return self._take_profit_pct.Value
@TakeProfitPct.setter
def TakeProfitPct(self, value):
self._take_profit_pct.Value = value
def OnReseted(self):
super(bull_row_breakout_strategy, self).OnReseted()
self._candles = []
self._entry_price = 0.0
self._has_prev = False
self._prev_close = 0.0
self._prev_ema = 0.0
def OnStarted2(self, time):
super(bull_row_breakout_strategy, self).OnStarted2(time)
self._candles = []
self._entry_price = 0.0
self._has_prev = False
self._prev_close = 0.0
self._prev_ema = 0.0
ema = ExponentialMovingAverage()
ema.Length = self.EmaPeriod
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(ema, self._process_candle).Start()
def _has_bull_row(self):
size = self.BullRowSize
if len(self._candles) < size:
return False
for i in range(size):
c = self._candles[-(i + 1)]
if float(c.ClosePrice) <= float(c.OpenPrice):
return False
return True
def _has_breakout(self):
lookback = self.BreakoutLookback
if len(self._candles) < lookback + 1:
return False
prev_close = float(self._candles[-1].ClosePrice)
highest = float('-inf')
for i in range(2, min(lookback + 1, len(self._candles) + 1)):
if i <= len(self._candles):
highest = max(highest, float(self._candles[-i].HighPrice))
return prev_close > highest
def _process_candle(self, candle, ema_value):
if candle.State != CandleStates.Finished:
return
close = float(candle.ClosePrice)
ema_val = float(ema_value)
# Check SL/TP on existing position
if self.Position != 0 and self._entry_price > 0:
if self.Position > 0:
pnl_pct = (close - self._entry_price) / self._entry_price * 100.0
if pnl_pct <= -float(self.StopLossPct) or pnl_pct >= float(self.TakeProfitPct):
self.SellMarket()
self._entry_price = 0.0
elif self.Position < 0:
pnl_pct = (self._entry_price - close) / self._entry_price * 100.0
if pnl_pct <= -float(self.StopLossPct) or pnl_pct >= float(self.TakeProfitPct):
self.BuyMarket()
self._entry_price = 0.0
self._candles.append(candle)
max_needed = max(self.BullRowSize, self.BreakoutLookback) + 5
if len(self._candles) > max_needed:
self._candles.pop(0)
if self._has_prev:
# Buy: EMA crossover + bull row + breakout
if self._prev_close <= self._prev_ema and close > ema_val and self._has_bull_row() and self.Position <= 0:
self.BuyMarket()
self._entry_price = close
elif self._prev_close >= self._prev_ema and close < ema_val and self.Position >= 0:
self.SellMarket()
self._entry_price = close
self._prev_close = close
self._prev_ema = ema_val
self._has_prev = True
def CreateClone(self):
return bull_row_breakout_strategy()