Percentage Crossover 策略
该策略复刻了 MetaTrader 专家 Exp_PercentageCrossover 的行为。它基于 Percentage Crossover 指标的方向进行交易。该指标绘制一条跟随价格的轨迹线,这条线只能在当前收盘价上下固定百分比的通道内移动,线条的斜率用来判断市场状态并生成信号。
思路
- 在每根完成的 K 线收盘时,指标都会保留上一根线的数值。
- 当收盘价把轨迹线向上推高超过此前数值,并且幅度不少于
percent所设定的百分比时,线条被视为向上更新。 - 当收盘价把轨迹线向下拉低超过此前数值,同样达到设定百分比时,线条被视为向下更新。
- 如果收盘价保持在百分比通道内部,线条保持水平并延续上一次的颜色。
线条颜色与 MetaTrader 中的解释一致:
- 颜色索引 0(紫色) —— 轨迹线上升,表示多头环境。
- 颜色索引 1(橙色) —— 轨迹线下降,表示空头环境。
交易规则
多头入场
- 仅在
BuyPosOpen = true时启用。 - 按照
SignalBar参数指定的那根已完成 K 线进行判断(1 表示最近一根收盘 K 线)。 - 当该 K 线由颜色 1 切换为颜色 0 时开多。
空头入场
- 仅在
SellPosOpen = true时启用。 - 使用同一根
SignalBarK 线。 - 当该 K 线由颜色 0 切换为颜色 1 时开空。
持仓管理
- 若
BuyPosClose = true,只要当前监测的 K 线颜色为 1,所有多头仓位都会被立即平掉。 - 若
SellPosClose = true,只要监测的 K 线颜色为 0,所有空头仓位都会被立即平掉。 - 当
UseTimeFilter = true且当前时间不在设定的交易窗口内时,策略会立刻平仓并忽略新信号,直到重新进入允许的时间范围。 - 策略通过
BuyMarket()与SellMarket()下单,实际数量由策略的Volume属性决定。
参数
| 参数 | 说明 | 默认值 |
|---|---|---|
Percent |
轨迹线与价格之间的百分比通道,数值越大,线条越迟钝。 | 1 |
SignalBar |
参与判断的已收盘 K 线编号(1 = 最近一根)。必须大于 0。 | 1 |
BuyPosOpen / SellPosOpen |
是否允许开多或开空。 | true |
BuyPosClose / SellPosClose |
是否启用多头或空头的平仓逻辑。 | true |
UseTimeFilter |
启用交易时间过滤。 | true |
StartHour / StartMinute |
交易窗口开始的小时与分钟。 | 0 / 0 |
EndHour / EndMinute |
交易窗口结束的小时与分钟。 | 23 / 59 |
CandleType |
用于计算指标与信号的 K 线周期。 | 4h |
说明
- 时间过滤严格遵循原始 EA 的实现。当
StartHour > EndHour时会形成跨日的交易窗口,但仍需要当前分钟数大于等于StartMinute才会生效。 SignalBar只针对已经收盘的 K 线进行运算。把该值设为1可以完全对应 MetaTrader 的默认设置。- 策略没有内置的止损或止盈。请通过外部风控或调整百分比与时间窗口来控制风险。
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 based on the Percentage Crossover indicator.
/// </summary>
public class PercentageCrossoverStrategy : Strategy
{
private readonly StrategyParam<bool> _buyPosOpen;
private readonly StrategyParam<bool> _sellPosOpen;
private readonly StrategyParam<bool> _buyPosClose;
private readonly StrategyParam<bool> _sellPosClose;
private readonly StrategyParam<bool> _useTimeFilter;
private readonly StrategyParam<int> _startHour;
private readonly StrategyParam<int> _startMinute;
private readonly StrategyParam<int> _endHour;
private readonly StrategyParam<int> _endMinute;
private readonly StrategyParam<decimal> _percent;
private readonly StrategyParam<int> _signalBar;
private readonly StrategyParam<DataType> _candleType;
private readonly List<int> _colorHistory = new();
private decimal? _previousMiddle;
private int? _lastColor;
public bool BuyPosOpen
{
get => _buyPosOpen.Value;
set => _buyPosOpen.Value = value;
}
public bool SellPosOpen
{
get => _sellPosOpen.Value;
set => _sellPosOpen.Value = value;
}
public bool BuyPosClose
{
get => _buyPosClose.Value;
set => _buyPosClose.Value = value;
}
public bool SellPosClose
{
get => _sellPosClose.Value;
set => _sellPosClose.Value = value;
}
public bool UseTimeFilter
{
get => _useTimeFilter.Value;
set => _useTimeFilter.Value = value;
}
public int StartHour
{
get => _startHour.Value;
set => _startHour.Value = value;
}
public int StartMinute
{
get => _startMinute.Value;
set => _startMinute.Value = value;
}
public int EndHour
{
get => _endHour.Value;
set => _endHour.Value = value;
}
public int EndMinute
{
get => _endMinute.Value;
set => _endMinute.Value = value;
}
public decimal Percent
{
get => _percent.Value;
set => _percent.Value = value;
}
public int SignalBar
{
get => _signalBar.Value;
set => _signalBar.Value = value;
}
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
public PercentageCrossoverStrategy()
{
_buyPosOpen = Param(nameof(BuyPosOpen), true)
.SetDisplay("Enable Buy Entries", "Allow opening long positions", "General");
_sellPosOpen = Param(nameof(SellPosOpen), true)
.SetDisplay("Enable Sell Entries", "Allow opening short positions", "General");
_buyPosClose = Param(nameof(BuyPosClose), true)
.SetDisplay("Enable Buy Exits", "Allow closing long positions", "General");
_sellPosClose = Param(nameof(SellPosClose), true)
.SetDisplay("Enable Sell Exits", "Allow closing short positions", "General");
_useTimeFilter = Param(nameof(UseTimeFilter), true)
.SetDisplay("Use Time Filter", "Restrict trading to specific hours", "Time Filter");
_startHour = Param(nameof(StartHour), 0)
.SetDisplay("Start Hour", "Trading window start hour", "Time Filter");
_startMinute = Param(nameof(StartMinute), 0)
.SetDisplay("Start Minute", "Trading window start minute", "Time Filter");
_endHour = Param(nameof(EndHour), 23)
.SetDisplay("End Hour", "Trading window end hour", "Time Filter");
_endMinute = Param(nameof(EndMinute), 59)
.SetDisplay("End Minute", "Trading window end minute", "Time Filter");
_percent = Param(nameof(Percent), 1m)
.SetGreaterThanZero()
.SetDisplay("Percent", "Percentage offset for the indicator", "Indicator");
_signalBar = Param(nameof(SignalBar), 1)
.SetGreaterThanZero()
.SetDisplay("Signal Bar", "Closed bars to look back for the signal", "Indicator");
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
.SetDisplay("Candle Type", "Time frame for signal candles", "Data");
}
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
protected override void OnReseted()
{
base.OnReseted();
_colorHistory.Clear();
_previousMiddle = null;
_lastColor = null;
}
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_colorHistory.Clear();
_previousMiddle = null;
_lastColor = null;
var subscription = SubscribeCandles(CandleType);
subscription.Bind(ProcessCandle).Start();
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var close = candle.ClosePrice;
var percentFactor = Percent / 100m;
if (_previousMiddle is null)
{
_previousMiddle = close;
_lastColor = 0;
_colorHistory.Clear();
_colorHistory.Add(0);
return;
}
var previousMiddle = _previousMiddle.Value;
var lowerBoundary = close * (1 - percentFactor);
var upperBoundary = close * (1 + percentFactor);
var middle = previousMiddle;
if (lowerBoundary > previousMiddle)
middle = lowerBoundary;
else if (upperBoundary < previousMiddle)
middle = upperBoundary;
var color = _lastColor ?? 0;
if (middle > previousMiddle)
color = 0;
else if (middle < previousMiddle)
color = 1;
_previousMiddle = middle;
_lastColor = color;
_colorHistory.Add(color);
var maxSize = Math.Max(SignalBar + 2, 4);
while (_colorHistory.Count > maxSize)
{
try { _colorHistory.RemoveAt(0); }
catch { break; }
}
var currentIndex = _colorHistory.Count - SignalBar;
if (currentIndex <= 0)
return;
var previousIndex = currentIndex - 1;
if (previousIndex < 0)
return;
var currentColor = _colorHistory[currentIndex];
var previousColor = _colorHistory[previousIndex];
var buyOpen = BuyPosOpen && currentColor == 0 && previousColor == 1;
var sellOpen = SellPosOpen && currentColor == 1 && previousColor == 0;
var buyClose = BuyPosClose && currentColor == 1;
var sellClose = SellPosClose && currentColor == 0;
var inTradingWindow = !UseTimeFilter || IsTradingTime(candle.CloseTime);
if (UseTimeFilter && !inTradingWindow)
{
if (Position > 0)
SellMarket();
else if (Position < 0)
BuyMarket();
return;
}
if (buyClose && Position > 0)
SellMarket();
if (sellClose && Position < 0)
BuyMarket();
if (!inTradingWindow)
return;
if (buyOpen && Position <= 0)
BuyMarket();
else if (sellOpen && Position >= 0)
SellMarket();
}
private bool IsTradingTime(DateTimeOffset time)
{
var hour = time.Hour;
var minute = time.Minute;
if (StartHour < EndHour)
{
if (hour == StartHour && minute >= StartMinute)
return true;
if (hour > StartHour && hour < EndHour)
return true;
if (hour > StartHour && hour == EndHour && minute < EndMinute)
return true;
return false;
}
if (StartHour == EndHour)
{
return hour == StartHour && minute >= StartMinute && minute < EndMinute;
}
if (hour >= StartHour && minute >= StartMinute)
return true;
if (hour < EndHour)
return true;
if (hour == EndHour && minute < EndMinute)
return true;
return false;
}
}
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_strategy(Strategy):
"""Percentage Crossover indicator-based strategy with time filter."""
def __init__(self):
super(percentage_crossover_strategy, self).__init__()
self._buy_pos_open = self.Param("BuyPosOpen", True) \
.SetDisplay("Enable Buy Entries", "Allow opening long positions", "General")
self._sell_pos_open = self.Param("SellPosOpen", True) \
.SetDisplay("Enable Sell Entries", "Allow opening short positions", "General")
self._buy_pos_close = self.Param("BuyPosClose", True) \
.SetDisplay("Enable Buy Exits", "Allow closing long positions", "General")
self._sell_pos_close = self.Param("SellPosClose", True) \
.SetDisplay("Enable Sell Exits", "Allow closing short positions", "General")
self._use_time_filter = self.Param("UseTimeFilter", True) \
.SetDisplay("Use Time Filter", "Restrict trading to specific hours", "Time Filter")
self._start_hour = self.Param("StartHour", 0) \
.SetDisplay("Start Hour", "Trading window start hour", "Time Filter")
self._start_minute = self.Param("StartMinute", 0) \
.SetDisplay("Start Minute", "Trading window start minute", "Time Filter")
self._end_hour = self.Param("EndHour", 23) \
.SetDisplay("End Hour", "Trading window end hour", "Time Filter")
self._end_minute = self.Param("EndMinute", 59) \
.SetDisplay("End Minute", "Trading window end minute", "Time Filter")
self._percent = self.Param("Percent", 1.0) \
.SetGreaterThanZero() \
.SetDisplay("Percent", "Percentage offset for the indicator", "Indicator")
self._signal_bar = self.Param("SignalBar", 1) \
.SetGreaterThanZero() \
.SetDisplay("Signal Bar", "Closed bars to look back for the signal", "Indicator")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(4))) \
.SetDisplay("Candle Type", "Time frame for signal candles", "Data")
self._color_history = []
self._prev_middle = None
self._last_color = None
@property
def BuyPosOpen(self):
return self._buy_pos_open.Value
@property
def SellPosOpen(self):
return self._sell_pos_open.Value
@property
def BuyPosClose(self):
return self._buy_pos_close.Value
@property
def SellPosClose(self):
return self._sell_pos_close.Value
@property
def UseTimeFilter(self):
return self._use_time_filter.Value
@property
def StartHour(self):
return self._start_hour.Value
@property
def StartMinute(self):
return self._start_minute.Value
@property
def EndHour(self):
return self._end_hour.Value
@property
def EndMinute(self):
return self._end_minute.Value
@property
def Percent(self):
return self._percent.Value
@property
def SignalBar(self):
return self._signal_bar.Value
@property
def CandleType(self):
return self._candle_type.Value
def OnStarted2(self, time):
super(percentage_crossover_strategy, self).OnStarted2(time)
self._color_history = []
self._prev_middle = None
self._last_color = None
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self.process_candle).Start()
def process_candle(self, candle):
if candle.State != CandleStates.Finished:
return
close = float(candle.ClosePrice)
pf = float(self.Percent) / 100.0
if self._prev_middle is None:
self._prev_middle = close
self._last_color = 0
self._color_history = [0]
return
prev_mid = self._prev_middle
lower_b = close * (1.0 - pf)
upper_b = close * (1.0 + pf)
middle = prev_mid
if lower_b > prev_mid:
middle = lower_b
elif upper_b < prev_mid:
middle = upper_b
color = self._last_color if self._last_color is not None else 0
if middle > prev_mid:
color = 0
elif middle < prev_mid:
color = 1
self._prev_middle = middle
self._last_color = color
self._color_history.append(color)
max_size = max(self.SignalBar + 2, 4)
while len(self._color_history) > max_size:
self._color_history.pop(0)
ci = len(self._color_history) - self.SignalBar
if ci <= 0:
return
pi = ci - 1
if pi < 0:
return
cc = self._color_history[ci]
pc = self._color_history[pi]
buy_open = self.BuyPosOpen and cc == 0 and pc == 1
sell_open = self.SellPosOpen and cc == 1 and pc == 0
buy_close = self.BuyPosClose and cc == 1
sell_close = self.SellPosClose and cc == 0
in_window = (not self.UseTimeFilter) or self._is_trading_time(candle.CloseTime)
if self.UseTimeFilter and not in_window:
if self.Position > 0:
self.SellMarket()
elif self.Position < 0:
self.BuyMarket()
return
if buy_close and self.Position > 0:
self.SellMarket()
if sell_close and self.Position < 0:
self.BuyMarket()
if not in_window:
return
if buy_open and self.Position <= 0:
self.BuyMarket()
elif sell_open and self.Position >= 0:
self.SellMarket()
def _is_trading_time(self, time):
h = time.Hour
m = time.Minute
sh = self.StartHour
sm = self.StartMinute
eh = self.EndHour
em = self.EndMinute
if sh < eh:
if h == sh and m >= sm:
return True
if h > sh and h < eh:
return True
if h > sh and h == eh and m < em:
return True
return False
if sh == eh:
return h == sh and m >= sm and m < em
if h >= sh and m >= sm:
return True
if h < eh:
return True
if h == eh and m < em:
return True
return False
def OnReseted(self):
super(percentage_crossover_strategy, self).OnReseted()
self._color_history = []
self._prev_middle = None
self._last_color = None
def CreateClone(self):
return percentage_crossover_strategy()