Powertrend - стратегия с фильтром диапазона по объёму
Стратегия строит динамические диапазоны на основе объёма и диапазона цен. Покупка происходит при пробое верхней границы в восходящем тренде, выход — при пробое нижней. Дополнительно можно включить фильтры ADX, тренда по максимумам/минимумам и VWMA.
Расчёт и исполнение
C# и Python используют следующее явное причинное определение. На завершённых свечах d = abs(Close - предыдущий Close), V = TotalVolume. После RangePeriod изменений r = RangeMultiplier * sum(d * V) / sum(V) по окну с текущей свечой. Суммарный объём должен быть положительным. Первое сформированное значение фильтра f равно текущему Close; далее предыдущее f ограничивается интервалом [Close - r, Close + r]. Границы текущего решения — предыдущее f + r и предыдущее f - r, до изменения центра. Поэтому верхняя граница не подменяется ценой пробоя после движения фильтра.
Вход объёмом Volume только из нулевой позиции при Close > верхняя граница и росте фильтра. При Close < нижняя граница рыночным ордером закрывается весь лонг независимо от фильтров входа. Равенство сохраняет позицию. Ордер в работе блокирует дополнительные ордера. Шортов, EMA-пересечений и cooldown нет.
Опциональный ADX должен сформироваться и строго превысить AdxThreshold; отключённый ADX не регистрируется и не навязывает прогрев. Подтверждение high/low требует одновременно текущий High > предыдущий High и текущий Low > предыдущий Low. VWMA значений фильтра — sum(f * V) / sum(V) по последним VwmaPeriod сформированным значениям, включая текущее. Нужны полное окно, положительный суммарный объём и Close > VWMA. Отключённые фильтры не требуют прогрева. Это рыночные решения по закрытию свечей, без биржевых стоп-заявок и предполагаемого исполнения по внутрисвечным high/low.
По умолчанию: RangePeriod=40, RangeMultiplier=1.5, AdxPeriod=14, AdxThreshold=20, VwmaPeriod=20, CandleType=TimeSpan.FromMinutes(5).TimeFrame(). IsAdxFilterEnabled, IsHighLowFilterEnabled, IsVwmaFilterEnabled — все false. Периоды и множитель положительны; порог ADX в [0, 100]. Эти формула и значения уточняют пример: исходное краткое описание не задавало формулу оригинального индикатора.
namespace StockSharp.Samples.Strategies;
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
/// <summary>
/// Long-only volume-weighted range breakouts with optional ADX, high/low and filter-value VWMA confirmation.
/// </summary>
public class PowertrendVolumeRangeFilterStrategy : Strategy
{
private readonly StrategyParam<int> _rangePeriod;
private readonly StrategyParam<decimal> _rangeMultiplier;
private readonly StrategyParam<int> _adxPeriod;
private readonly StrategyParam<decimal> _adxThreshold;
private readonly StrategyParam<int> _vwmaPeriod;
private readonly StrategyParam<bool> _isAdxFilterEnabled;
private readonly StrategyParam<bool> _isHighLowFilterEnabled;
private readonly StrategyParam<bool> _isVwmaFilterEnabled;
private readonly StrategyParam<DataType> _candleType;
private readonly Queue<(decimal delta, decimal volume)> _changes = [];
private readonly Queue<(decimal price, decimal volume)> _filtered = [];
private decimal? _previousClose;
private decimal? _previousHigh;
private decimal? _previousLow;
private decimal? _filter;
private decimal _weightedRange;
private decimal _rangeVolume;
private decimal _weightedFilter;
private decimal _filterVolume;
private Order _pendingOrder;
/// <summary>Initializes a 40-change range multiplied by 1.5, disabled optional filters and five-minute candles.</summary>
public PowertrendVolumeRangeFilterStrategy()
{
_rangePeriod = Param(nameof(RangePeriod), 40).SetGreaterThanZero()
.SetDisplay("Range Period", "Volume-weighted absolute close changes", "Range");
_rangeMultiplier = Param(nameof(RangeMultiplier), 1.5m).SetGreaterThanZero()
.SetDisplay("Range Multiplier", "Multiplier of the volume-weighted price-change range", "Range");
_adxPeriod = Param(nameof(AdxPeriod), 14).SetGreaterThanZero()
.SetDisplay("ADX Period", "Native directional-index period", "Filters");
_adxThreshold = Param(nameof(AdxThreshold), 20m).SetRange(0m, 100m)
.SetDisplay("ADX Threshold", "Strict lower bound on formed ADX for entries", "Filters");
_vwmaPeriod = Param(nameof(VwmaPeriod), 20).SetGreaterThanZero()
.SetDisplay("VWMA Period", "Volume-weighted average of formed filter values", "Filters");
_isAdxFilterEnabled = Param(nameof(IsAdxFilterEnabled), false)
.SetDisplay("ADX Enabled", "Require formed ADX above its threshold", "Filters");
_isHighLowFilterEnabled = Param(nameof(IsHighLowFilterEnabled), false)
.SetDisplay("High/Low Enabled", "Require both a higher high and a higher low", "Filters");
_isVwmaFilterEnabled = Param(nameof(IsVwmaFilterEnabled), false)
.SetDisplay("VWMA Enabled", "Require close above the formed filter-value VWMA", "Filters");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Finished candles supplying price and true total volume", "General");
OrderRegistering += order => _pendingOrder = order;
}
/// <summary>Number of close changes used to form a volume-weighted range.</summary>
public int RangePeriod { get => _rangePeriod.Value; set => _rangePeriod.Value = value; }
/// <summary>Positive multiplier applied to the weighted range.</summary>
public decimal RangeMultiplier { get => _rangeMultiplier.Value; set => _rangeMultiplier.Value = value; }
/// <summary>Native ADX length.</summary>
public int AdxPeriod { get => _adxPeriod.Value; set => _adxPeriod.Value = value; }
/// <summary>Strict ADX entry threshold in [0, 100].</summary>
public decimal AdxThreshold { get => _adxThreshold.Value; set => _adxThreshold.Value = value; }
/// <summary>Number of formed filter values in the optional VWMA.</summary>
public int VwmaPeriod { get => _vwmaPeriod.Value; set => _vwmaPeriod.Value = value; }
/// <summary>Require a formed native ADX above the threshold for entry.</summary>
public bool IsAdxFilterEnabled { get => _isAdxFilterEnabled.Value; set => _isAdxFilterEnabled.Value = value; }
/// <summary>Require current high and low to exceed the previous candle's high and low.</summary>
public bool IsHighLowFilterEnabled { get => _isHighLowFilterEnabled.Value; set => _isHighLowFilterEnabled.Value = value; }
/// <summary>Require current close above the formed filter-value VWMA.</summary>
public bool IsVwmaFilterEnabled { get => _isVwmaFilterEnabled.Value; set => _isVwmaFilterEnabled.Value = value; }
/// <summary>Base candle type; five minutes by default.</summary>
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, CandleType)];
private void ResetState()
{
_changes.Clear();
_filtered.Clear();
_previousClose = _previousHigh = _previousLow = _filter = null;
_weightedRange = _rangeVolume = _weightedFilter = _filterVolume = 0m;
_pendingOrder = null;
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
ResetState();
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (RangePeriod <= 0 || RangeMultiplier <= 0m || AdxPeriod <= 0 || AdxThreshold < 0m
|| AdxThreshold > 100m || VwmaPeriod <= 0)
throw new ArgumentException("Range/ADX/VWMA periods and multiplier must be positive; ADX threshold must be within [0, 100].");
ResetState();
var adx = new AverageDirectionalIndex { Length = AdxPeriod };
var subscription = SubscribeCandles(CandleType);
if (IsAdxFilterEnabled)
subscription.BindEx([adx], ProcessCandle, allowEmpty: true).Start();
else
subscription.Bind(candle => ProcessCandle(candle, [])).Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
if (IsAdxFilterEnabled)
DrawIndicator(area, adx);
DrawOwnTrades(area);
}
}
private void Submit(Sides side, decimal volume)
=> RegisterOrder(new Order { Security = Security, Portfolio = Portfolio, Type = OrderTypes.Market, Side = side, Volume = volume });
private void ProcessCandle(ICandleMessage candle, IIndicatorValue[] values)
{
if (candle.State != CandleStates.Finished)
return;
var higherHighLow = _previousHigh is decimal high && _previousLow is decimal low
&& candle.HighPrice > high && candle.LowPrice > low;
_previousHigh = candle.HighPrice;
_previousLow = candle.LowPrice;
if (_previousClose is decimal previous)
{
var delta = Math.Abs(candle.ClosePrice - previous);
_changes.Enqueue((delta, candle.TotalVolume));
_weightedRange += delta * candle.TotalVolume;
_rangeVolume += candle.TotalVolume;
if (_changes.Count > RangePeriod)
{
var removed = _changes.Dequeue();
_weightedRange -= removed.delta * removed.volume;
_rangeVolume -= removed.volume;
}
}
_previousClose = candle.ClosePrice;
if (_changes.Count < RangePeriod || _rangeVolume <= 0m)
return;
var width = RangeMultiplier * _weightedRange / _rangeVolume;
var prior = _filter;
if (prior is null)
_filter = candle.ClosePrice;
else if (candle.ClosePrice > prior + width)
_filter = candle.ClosePrice - width;
else if (candle.ClosePrice < prior - width)
_filter = candle.ClosePrice + width;
_filtered.Enqueue((_filter.Value, candle.TotalVolume));
_weightedFilter += _filter.Value * candle.TotalVolume;
_filterVolume += candle.TotalVolume;
if (_filtered.Count > VwmaPeriod)
{
var removed = _filtered.Dequeue();
_weightedFilter -= removed.price * removed.volume;
_filterVolume -= removed.volume;
}
if (prior is not decimal center || !IsFormedAndOnlineAndAllowTrading()
|| _pendingOrder is not null && _pendingOrder.State is not (OrderStates.Done or OrderStates.Failed))
return;
if (Position > 0m && candle.ClosePrice < center - width)
{
Submit(Sides.Sell, Position);
return;
}
if (Position != 0m || width <= 0m || candle.ClosePrice <= center + width || _filter <= center
|| IsHighLowFilterEnabled && !higherHighLow
|| IsVwmaFilterEnabled && (_filtered.Count < VwmaPeriod || _filterVolume <= 0m || candle.ClosePrice <= _weightedFilter / _filterVolume))
return;
if (IsAdxFilterEnabled && (!values[0].IsFormed
|| ((IAverageDirectionalIndexValue)values[0]).MovingAverage is not decimal strength || strength <= AdxThreshold))
return;
Submit(Sides.Buy, Volume);
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from collections import deque
from System import Array, ArgumentException, Decimal, TimeSpan
from StockSharp.Messages import CandleStates, DataType, OrderStates, OrderTypes, Sides
from StockSharp.BusinessEntities import Order
from StockSharp.Algo.Indicators import AverageDirectionalIndex, IIndicator
from StockSharp.Algo.Strategies import Strategy
class powertrend_volume_range_filter_strategy(Strategy):
def __init__(self):
super(powertrend_volume_range_filter_strategy, self).__init__()
self._range_period = self.Param("RangePeriod", 40).SetGreaterThanZero() \
.SetDisplay("Range Period", "Volume-weighted absolute close changes", "Range")
self._range_multiplier = self.Param("RangeMultiplier", Decimal.Parse("1.5")).SetGreaterThanZero() \
.SetDisplay("Range Multiplier", "Multiplier of the volume-weighted price-change range", "Range")
self._adx_period = self.Param("AdxPeriod", 14).SetGreaterThanZero() \
.SetDisplay("ADX Period", "Native directional-index period", "Filters")
self._adx_threshold = self.Param("AdxThreshold", Decimal(20)).SetRange(Decimal.Zero, Decimal(100)) \
.SetDisplay("ADX Threshold", "Strict lower bound on formed ADX for entries", "Filters")
self._vwma_period = self.Param("VwmaPeriod", 20).SetGreaterThanZero() \
.SetDisplay("VWMA Period", "Volume-weighted average of formed filter values", "Filters")
self._is_adx_filter_enabled = self.Param("IsAdxFilterEnabled", False) \
.SetDisplay("ADX Enabled", "Require formed ADX above its threshold", "Filters")
self._is_high_low_filter_enabled = self.Param("IsHighLowFilterEnabled", False) \
.SetDisplay("High/Low Enabled", "Require both a higher high and a higher low", "Filters")
self._is_vwma_filter_enabled = self.Param("IsVwmaFilterEnabled", False) \
.SetDisplay("VWMA Enabled", "Require close above the formed filter-value VWMA", "Filters")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))) \
.SetDisplay("Candle Type", "Finished candles supplying price and true total volume", "General")
self._reset_state()
self.OrderRegistering += self._observe_order
@property
def candle_type(self):
return self._candle_type.Value
@candle_type.setter
def candle_type(self, value):
self._candle_type.Value = value
def _reset_state(self):
self._changes = deque()
self._filtered = deque()
self._previous_close = self._previous_high = self._previous_low = self._filter = None
self._weighted_range = self._range_volume = self._weighted_filter = self._filter_volume = Decimal.Zero
self._pending_order = None
def OnReseted(self):
super(powertrend_volume_range_filter_strategy, self).OnReseted()
self._reset_state()
def _observe_order(self, order):
self._pending_order = order
def OnStarted2(self, time):
super(powertrend_volume_range_filter_strategy, self).OnStarted2(time)
if self._range_period.Value <= 0 or self._range_multiplier.Value <= 0 or self._adx_period.Value <= 0 \
or self._adx_threshold.Value < 0 or self._adx_threshold.Value > 100 or self._vwma_period.Value <= 0:
raise ArgumentException("Range/ADX/VWMA periods and multiplier must be positive; ADX threshold must be within [0, 100].")
self._reset_state()
subscription = self.SubscribeCandles(self.candle_type)
if self._is_adx_filter_enabled.Value:
adx = AverageDirectionalIndex()
adx.Length = self._adx_period.Value
subscription.BindEx(Array[IIndicator]([adx]), self.OnProcess, True).Start()
else:
subscription.Bind(self._without_adx).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawOwnTrades(area)
def _without_adx(self, candle):
self.OnProcess(candle, [])
def _submit(self, side, volume):
order = Order()
order.Security = self.Security
order.Portfolio = self.Portfolio
order.Type = OrderTypes.Market
order.Side = side
order.Volume = volume
self.RegisterOrder(order)
def OnProcess(self, candle, values):
if candle.State != CandleStates.Finished:
return
higher_high_low = self._previous_high is not None and self._previous_low is not None \
and candle.HighPrice > self._previous_high and candle.LowPrice > self._previous_low
self._previous_high = candle.HighPrice
self._previous_low = candle.LowPrice
if self._previous_close is not None:
delta = abs(candle.ClosePrice - self._previous_close)
self._changes.append((delta, candle.TotalVolume))
self._weighted_range += delta * candle.TotalVolume
self._range_volume += candle.TotalVolume
if len(self._changes) > self._range_period.Value:
removed = self._changes.popleft()
self._weighted_range -= removed[0] * removed[1]
self._range_volume -= removed[1]
self._previous_close = candle.ClosePrice
if len(self._changes) < self._range_period.Value or self._range_volume <= 0:
return
width = self._range_multiplier.Value * self._weighted_range / self._range_volume
prior = self._filter
if prior is None:
self._filter = candle.ClosePrice
elif candle.ClosePrice > prior + width:
self._filter = candle.ClosePrice - width
elif candle.ClosePrice < prior - width:
self._filter = candle.ClosePrice + width
self._filtered.append((self._filter, candle.TotalVolume))
self._weighted_filter += self._filter * candle.TotalVolume
self._filter_volume += candle.TotalVolume
if len(self._filtered) > self._vwma_period.Value:
removed = self._filtered.popleft()
self._weighted_filter -= removed[0] * removed[1]
self._filter_volume -= removed[1]
pending = self._pending_order
if prior is None or not self.IsFormedAndOnlineAndAllowTrading() \
or pending is not None and pending.State != OrderStates.Done and pending.State != OrderStates.Failed:
return
if self.Position > 0 and candle.ClosePrice < prior - width:
self._submit(Sides.Sell, self.Position)
return
if self.Position != 0 or width <= 0 or candle.ClosePrice <= prior + width or self._filter <= prior \
or self._is_high_low_filter_enabled.Value and not higher_high_low \
or self._is_vwma_filter_enabled.Value and (len(self._filtered) < self._vwma_period.Value \
or self._filter_volume <= 0 or candle.ClosePrice <= self._weighted_filter / self._filter_volume):
return
if self._is_adx_filter_enabled.Value:
value = values[0]
strength = value.MovingAverage
if not value.IsFormed or strength is None or strength <= self._adx_threshold.Value:
return
self._submit(Sides.Buy, self.Volume)
def CreateClone(self):
return powertrend_volume_range_filter_strategy()