百分比通道系统策略
该策略是 MetaTrader 专家顾问 Exp_PercentageCrossoverChannel_System 的直接移植版。策略追踪价格与自定义“Percentage Crossover Channel”指标的互动,当蜡烛在突破后重新回到通道内部时做出反应。所有信号流程均按照 StockSharp 的高层 API 重新实现。
交易逻辑
指标构建
- Percentage Crossover Channel 构建一条跟随价格的自适应中线,中线的偏离速度不会超过设定百分比 (
Percent)。 - 上轨与下轨在中线上方/下方按相同百分比绘制。
- 每根收盘的蜡烛都会根据
Shift根之前的通道位置被赋予颜色:- 颜色
3/4:收盘价位于上轨之上(分别表示阴/阳线)。 - 颜色
0/1:收盘价位于下轨之下(分别表示阴/阳线)。 - 颜色
2:收盘价位于通道内部。
- 颜色
- Percentage Crossover Channel 构建一条跟随价格的自适应中线,中线的偏离速度不会超过设定百分比 (
入场与出场
- 评估最近
SignalBar根蜡烛及其前一根蜡烛,完全复刻 MQL 中的CopyBuffer调用。 - 多头序列(
olderColor > 2):市场最近在通道上方收盘。如果最新蜡烛重新回到通道内(recentColor < 3),则:- 在启用
SellPositionsClose时平掉所有空头仓位。 - 在仓位为空且
BuyPositionsOpen启用的情况下开多。
- 在启用
- 空头序列(
olderColor < 2):市场最近在通道下方收盘。如果最新蜡烛回到通道内(recentColor > 1),则:- 在启用
BuyPositionsClose时平掉所有多头仓位。 - 在仓位为空且
SellPositionsOpen启用的情况下开空。
- 在启用
- 策略因此等待“突破 + 回踩”组合后顺势入场。
- 评估最近
风险控制
- 可选的止损与止盈以价格步长为单位设置,并基于蜡烛最高价/最低价触发。
- 一旦保护性指令触发,策略立即离场,并在同一根蜡烛内忽略新的进场信号,模拟原始 EA 中经纪商侧止损优先执行的行为。
参数说明
| 参数 | 说明 |
|---|---|
Percent |
通道宽度,单位为百分比,对应 MQL 指标参数。 |
Shift |
用于比较突破的回溯蜡烛数量。 |
SignalBar |
信号评估所使用的偏移量(以蜡烛数计),默认值 1 表示上一根蜡烛。 |
BuyPositionsOpen / SellPositionsOpen |
是否允许开多/开空。 |
BuyPositionsClose / SellPositionsClose |
是否允许在出现反向信号时强制平仓。 |
StopLoss |
止损距离,以 Security.PriceStep 的倍数表示。0 表示不使用。 |
TakeProfit |
止盈距离,同样以价格步长表示。0 表示不使用。 |
CandleType |
使用的蜡烛类型(时间框架),默认对应四小时周期 PERIOD_H4。 |
实现细节
- 由于 StockSharp 没有自带 Percentage Crossover Channel 指标,算法在策略内部重写,包括中线递推、上下轨以及颜色判定,步骤与 MQL 代码一致。
- 持仓管理遵循原始的
BuyPositionOpen/SellPositionOpen等辅助函数:先平掉反向仓位,再尝试开新仓,并在存在反向持仓时跳过信号。 - MQL 附件中的资金管理、
Deviation滑点参数以及不同保证金模式的手数计算未被移植。请通过 StockSharp 的常规属性或外部平台配置下单量。 - 止损/止盈被解释为“价格步长”的倍数,对应 MetaTrader 中的“点数”。请确认所连接的标的提供有效的
PriceStep。
使用建议
若希望复制 MetaTrader 的表现,请在高质量的四小时数据上运行该策略;也可以调整
CandleType用于日内交易测试。信号需要至少两根带有效颜色信息的已完成蜡烛,因此初始化时应确保历史数据不少于
Shift + SignalBar + 1根。Percent对策略灵敏度影响显著:数值越小,通道越贴近价格、交易越频繁;数值越大,则仅关注强势突破。策略始终保持单仓结构,只会在多头、空头或空仓三种状态之间切换,进行组合风控时需考虑这一点。
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>
/// Percentage Crossover Channel breakout system translated from MQL.
/// </summary>
public class PercentageCrossoverChannelSystemStrategy : Strategy
{
private readonly StrategyParam<decimal> _percent;
private readonly StrategyParam<int> _shift;
private readonly StrategyParam<int> _signalBar;
private readonly StrategyParam<bool> _buyOpen;
private readonly StrategyParam<bool> _sellOpen;
private readonly StrategyParam<bool> _buyClose;
private readonly StrategyParam<bool> _sellClose;
private readonly StrategyParam<int> _stopLoss;
private readonly StrategyParam<int> _takeProfit;
private readonly StrategyParam<DataType> _candleType;
private readonly List<int> _colorHistory = new();
private readonly List<decimal> _upperHistory = new();
private readonly List<decimal> _lowerHistory = new();
private decimal _previousMiddle;
private bool _hasMiddle;
private decimal? _entryPrice;
public decimal Percent
{
get => _percent.Value;
set => _percent.Value = value;
}
public int Shift
{
get => _shift.Value;
set => _shift.Value = value;
}
public int SignalBar
{
get => _signalBar.Value;
set => _signalBar.Value = value;
}
public bool BuyPositionsOpen
{
get => _buyOpen.Value;
set => _buyOpen.Value = value;
}
public bool SellPositionsOpen
{
get => _sellOpen.Value;
set => _sellOpen.Value = value;
}
public bool BuyPositionsClose
{
get => _buyClose.Value;
set => _buyClose.Value = value;
}
public bool SellPositionsClose
{
get => _sellClose.Value;
set => _sellClose.Value = value;
}
public int StopLoss
{
get => _stopLoss.Value;
set => _stopLoss.Value = value;
}
public int TakeProfit
{
get => _takeProfit.Value;
set => _takeProfit.Value = value;
}
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
public PercentageCrossoverChannelSystemStrategy()
{
_percent = Param(nameof(Percent), 1.0m)
.SetGreaterThanZero()
.SetDisplay("Channel Percent", "Percentage width of the channel", "Indicator");
_shift = Param(nameof(Shift), 1)
.SetGreaterThanZero()
.SetDisplay("Shift", "Number of bars used for crossover comparison", "Indicator");
_signalBar = Param(nameof(SignalBar), 1)
.SetGreaterThanZero()
.SetDisplay("Signal Bar", "Bars back to evaluate indicator colors", "Trading Rules");
_buyOpen = Param(nameof(BuyPositionsOpen), true)
.SetDisplay("Enable Long Entries", "Allow long position openings", "Trading Rules");
_sellOpen = Param(nameof(SellPositionsOpen), true)
.SetDisplay("Enable Short Entries", "Allow short position openings", "Trading Rules");
_buyClose = Param(nameof(BuyPositionsClose), true)
.SetDisplay("Allow Long Exits", "Permit closing long trades on bearish signals", "Trading Rules");
_sellClose = Param(nameof(SellPositionsClose), true)
.SetDisplay("Allow Short Exits", "Permit closing short trades on bullish signals", "Trading Rules");
_stopLoss = Param(nameof(StopLoss), 1000)
.SetDisplay("Stop Loss (steps)", "Protective stop loss distance in price steps", "Risk Management");
_takeProfit = Param(nameof(TakeProfit), 2000)
.SetDisplay("Take Profit (steps)", "Target profit distance in price steps", "Risk Management");
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
.SetDisplay("Candle Type", "Primary timeframe for analysis", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_colorHistory.Clear();
_upperHistory.Clear();
_lowerHistory.Clear();
_hasMiddle = false;
_previousMiddle = 0m;
_entryPrice = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
var subscription = SubscribeCandles(CandleType);
subscription.Bind(ProcessCandle).Start();
}
private void ProcessCandle(ICandleMessage candle)
{
// Ignore interim updates; we only react on closed candles.
if (candle.State != CandleStates.Finished)
return;
// Evaluate protective orders before generating new signals.
var stopTriggered = HandleRisk(candle);
// Mirror the MQL signal logic using cached indicator colors.
if (_colorHistory.Count > SignalBar)
{
// Equivalent to CopyBuffer(..., SignalBar, 2, ...) from the EA.
var recentIndex = _colorHistory.Count - SignalBar;
var olderIndex = recentIndex - 1;
if (olderIndex >= 0)
{
var recentColor = _colorHistory[recentIndex];
var olderColor = _colorHistory[olderIndex];
var shouldCloseShort = SellPositionsClose && olderColor > 2;
var shouldCloseLong = BuyPositionsClose && olderColor < 2;
var shouldOpenBuy = BuyPositionsOpen && olderColor > 2 && recentColor < 3;
var shouldOpenSell = SellPositionsOpen && olderColor < 2 && recentColor > 1;
// Close existing positions according to the original toggles.
if (shouldCloseLong && Position > 0)
{
SellMarket();
_entryPrice = null;
}
if (shouldCloseShort && Position < 0)
{
BuyMarket();
_entryPrice = null;
}
// Enter only when we are flat to match the EA behaviour.
if (!stopTriggered && Position == 0)
{
if (shouldOpenBuy)
{
BuyMarket();
_entryPrice = candle.ClosePrice;
}
else if (shouldOpenSell)
{
SellMarket();
_entryPrice = candle.ClosePrice;
}
}
}
}
// Update indicator state after trading decisions are made.
var color = CalculateColor(candle);
_colorHistory.Add(color);
TrimHistory();
}
private bool HandleRisk(ICandleMessage candle)
{
// Exit early if there is no stored entry price.
if (_entryPrice is null)
return false;
// Price step is required to translate MQL points into absolute prices.
if (Security?.PriceStep is not decimal step || step <= 0)
return false;
var triggered = false;
if (Position > 0)
{
// Long position risk checks.
if (StopLoss > 0)
{
var stopLevel = _entryPrice.Value - StopLoss * step;
if (candle.LowPrice <= stopLevel)
{
SellMarket();
_entryPrice = null;
triggered = true;
}
}
if (!triggered && TakeProfit > 0)
{
var takeLevel = _entryPrice.Value + TakeProfit * step;
if (candle.HighPrice >= takeLevel)
{
SellMarket();
_entryPrice = null;
triggered = true;
}
}
}
else if (Position < 0)
{
// Short position risk checks.
if (StopLoss > 0)
{
var stopLevel = _entryPrice.Value + StopLoss * step;
if (candle.HighPrice >= stopLevel)
{
BuyMarket();
_entryPrice = null;
triggered = true;
}
}
if (!triggered && TakeProfit > 0)
{
var takeLevel = _entryPrice.Value - TakeProfit * step;
if (candle.LowPrice <= takeLevel)
{
BuyMarket();
_entryPrice = null;
triggered = true;
}
}
}
// Reset cached entry price once we are flat.
if (Position == 0)
_entryPrice = null;
return triggered;
}
private int CalculateColor(ICandleMessage candle)
{
// Recreate the Percentage Crossover Channel midline and colour logic.
var percentFactor = Percent / 100m;
var plusVar = 1m + percentFactor;
var minusVar = 1m - percentFactor;
var close = candle.ClosePrice;
// Initialise the midline on the very first candle.
if (!_hasMiddle)
{
_previousMiddle = close;
_hasMiddle = true;
}
var middle = _previousMiddle;
var lowerCandidate = close * minusVar;
var upperCandidate = close * plusVar;
// Adjust the midline exactly as in the original indicator.
if (lowerCandidate > _previousMiddle)
{
middle = lowerCandidate;
}
else if (upperCandidate < _previousMiddle)
{
middle = upperCandidate;
}
var upper = middle + middle * percentFactor;
var lower = middle - middle * percentFactor;
_previousMiddle = middle;
var color = 2;
// Determine candle colour relative to past channel values.
if (_upperHistory.Count >= Shift)
{
var referenceIndex = _upperHistory.Count - Shift;
var referenceUpper = _upperHistory[referenceIndex];
var referenceLower = _lowerHistory[referenceIndex];
if (close > referenceUpper)
{
color = candle.OpenPrice <= close ? 4 : 3;
}
else if (close < referenceLower)
{
color = candle.OpenPrice > close ? 0 : 1;
}
}
// Persist channel history for future signal checks.
_upperHistory.Add(upper);
_lowerHistory.Add(lower);
return color;
}
private void TrimHistory()
{
// Keep only as much history as needed for Shift and SignalBar lookbacks.
var maxCapacity = Math.Max(Shift + SignalBar + 5, 16);
if (_colorHistory.Count <= maxCapacity)
return;
var removeCount = _colorHistory.Count - maxCapacity;
for (var i = 0; i < removeCount; i++)
{
try
{
_colorHistory.RemoveAt(0);
_upperHistory.RemoveAt(0);
_lowerHistory.RemoveAt(0);
}
catch { break; }
}
}
}
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 percentage_crossover_channel_system_strategy(Strategy):
"""Percentage Crossover Channel breakout system with SL/TP."""
def __init__(self):
super(percentage_crossover_channel_system_strategy, self).__init__()
self._percent = self.Param("Percent", 1.0) \
.SetGreaterThanZero() \
.SetDisplay("Channel Percent", "Percentage width of the channel", "Indicator")
self._shift = self.Param("Shift", 1) \
.SetGreaterThanZero() \
.SetDisplay("Shift", "Number of bars used for crossover comparison", "Indicator")
self._signal_bar = self.Param("SignalBar", 1) \
.SetGreaterThanZero() \
.SetDisplay("Signal Bar", "Bars back to evaluate indicator colors", "Trading Rules")
self._buy_open = self.Param("BuyPositionsOpen", True) \
.SetDisplay("Enable Long Entries", "Allow long position openings", "Trading Rules")
self._sell_open = self.Param("SellPositionsOpen", True) \
.SetDisplay("Enable Short Entries", "Allow short position openings", "Trading Rules")
self._buy_close = self.Param("BuyPositionsClose", True) \
.SetDisplay("Allow Long Exits", "Permit closing long trades on bearish signals", "Trading Rules")
self._sell_close = self.Param("SellPositionsClose", True) \
.SetDisplay("Allow Short Exits", "Permit closing short trades on bullish signals", "Trading Rules")
self._stop_loss = self.Param("StopLoss", 1000) \
.SetDisplay("Stop Loss (steps)", "Protective stop loss distance in price steps", "Risk Management")
self._take_profit = self.Param("TakeProfit", 2000) \
.SetDisplay("Take Profit (steps)", "Target profit distance in price steps", "Risk Management")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(1))) \
.SetDisplay("Candle Type", "Primary timeframe for analysis", "General")
self._color_history = []
self._upper_history = []
self._lower_history = []
self._prev_middle = 0.0
self._has_middle = False
self._entry_price = None
@property
def Percent(self):
return self._percent.Value
@property
def Shift(self):
return self._shift.Value
@property
def SignalBar(self):
return self._signal_bar.Value
@property
def BuyPositionsOpen(self):
return self._buy_open.Value
@property
def SellPositionsOpen(self):
return self._sell_open.Value
@property
def BuyPositionsClose(self):
return self._buy_close.Value
@property
def SellPositionsClose(self):
return self._sell_close.Value
@property
def StopLoss(self):
return self._stop_loss.Value
@property
def TakeProfit(self):
return self._take_profit.Value
@property
def CandleType(self):
return self._candle_type.Value
def OnStarted2(self, time):
super(percentage_crossover_channel_system_strategy, self).OnStarted2(time)
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self.process_candle).Start()
def process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
stop_triggered = self._handle_risk(candle)
if len(self._color_history) > self.SignalBar:
ri = len(self._color_history) - self.SignalBar
oi = ri - 1
if oi >= 0:
rc = self._color_history[ri]
oc = self._color_history[oi]
should_close_short = self.SellPositionsClose and oc > 2
should_close_long = self.BuyPositionsClose and oc < 2
should_buy = self.BuyPositionsOpen and oc > 2 and rc < 3
should_sell = self.SellPositionsOpen and oc < 2 and rc > 1
if should_close_long and self.Position > 0:
self.SellMarket()
self._entry_price = None
if should_close_short and self.Position < 0:
self.BuyMarket()
self._entry_price = None
if not stop_triggered and self.Position == 0:
if should_buy:
self.BuyMarket()
self._entry_price = float(candle.ClosePrice)
elif should_sell:
self.SellMarket()
self._entry_price = float(candle.ClosePrice)
color = self._calc_color(candle)
self._color_history.append(color)
self._trim()
def _handle_risk(self, candle):
if self._entry_price is None:
return False
sec = self.Security
if sec is None or sec.PriceStep is None or float(sec.PriceStep) <= 0:
return False
step = float(sec.PriceStep)
triggered = False
if self.Position > 0:
if self.StopLoss > 0:
sl = self._entry_price - self.StopLoss * step
if float(candle.LowPrice) <= sl:
self.SellMarket()
self._entry_price = None
triggered = True
if not triggered and self.TakeProfit > 0:
tp = self._entry_price + self.TakeProfit * step
if float(candle.HighPrice) >= tp:
self.SellMarket()
self._entry_price = None
triggered = True
elif self.Position < 0:
if self.StopLoss > 0:
sl = self._entry_price + self.StopLoss * step
if float(candle.HighPrice) >= sl:
self.BuyMarket()
self._entry_price = None
triggered = True
if not triggered and self.TakeProfit > 0:
tp = self._entry_price - self.TakeProfit * step
if float(candle.LowPrice) <= tp:
self.BuyMarket()
self._entry_price = None
triggered = True
if self.Position == 0:
self._entry_price = None
return triggered
def _calc_color(self, candle):
pf = float(self.Percent) / 100.0
plus_var = 1.0 + pf
minus_var = 1.0 - pf
close = float(candle.ClosePrice)
if not self._has_middle:
self._prev_middle = close
self._has_middle = True
middle = self._prev_middle
lower_c = close * minus_var
upper_c = close * plus_var
if lower_c > self._prev_middle:
middle = lower_c
elif upper_c < self._prev_middle:
middle = upper_c
upper = middle + middle * pf
lower = middle - middle * pf
self._prev_middle = middle
color = 2
if len(self._upper_history) >= self.Shift:
ref_idx = len(self._upper_history) - self.Shift
ref_upper = self._upper_history[ref_idx]
ref_lower = self._lower_history[ref_idx]
if close > ref_upper:
color = 4 if float(candle.OpenPrice) <= close else 3
elif close < ref_lower:
color = 0 if float(candle.OpenPrice) > close else 1
self._upper_history.append(upper)
self._lower_history.append(lower)
return color
def _trim(self):
max_cap = max(self.Shift + self.SignalBar + 5, 16)
if len(self._color_history) <= max_cap:
return
remove = len(self._color_history) - max_cap
self._color_history = self._color_history[remove:]
self._upper_history = self._upper_history[remove:]
self._lower_history = self._lower_history[remove:]
def OnReseted(self):
super(percentage_crossover_channel_system_strategy, self).OnReseted()
self._color_history = []
self._upper_history = []
self._lower_history = []
self._has_middle = False
self._prev_middle = 0.0
self._entry_price = None
def CreateClone(self):
return percentage_crossover_channel_system_strategy()