Hull MA Implied Volatility Breakout
Правила торговли
HMA = WMA(2 × WMA(Close, floor(HmaPeriod/2)) − WMA(Close, HmaPeriod), floor(sqrt(HmaPeriod))). Нужны сформированная HMA и её предыдущее сформированное значение. Из нулевой позиции: лонг при росте HMA, шорт при снижении, только если наблюдаемая IV > предыдущее среднее + IVMultiplier × σ. IV подаётся из опционного источника в единой единице измерения, а не рассчитывается из диапазона свечи. Падение HMA закрывает лонг, рост закрывает шорт даже при пропуске IV или отсутствии выброса. Равенство HMA не даёт сигнала.
Среднее и выборочное стандартное отклонение вычисляются по предыдущим N последовательным наблюдениям без текущего: σ = sqrt(Σ(x − mean)² / (N − 1)). Нужен полный период. Случайные значения и ценовые суррогаты не используются.
Входные данные
Обязательный CSV имеет заголовок SecurityId,Time,Value. Time — время открытия сигнальной свечи UTC в формате yyyy-MM-ddTHH:mm:ssZ. Значение — наблюдаемая статистика для торгуемого инструмента: конечное, неотрицательное, уникальное для времени. Используется только наблюдение текущей свечи. Пропуск очищает окно фильтра и запрещает входы до нового прогрева; сигнальные выходы продолжают работать.
Параметры
| Параметр | Значение по умолчанию |
|---|---|
HmaPeriod |
9 |
IVPeriod |
20 |
IVMultiplier |
2 |
IVHistoryFile |
обязательный |
AtrPeriod |
14 |
StopLossAtr |
2 |
CandleType |
TimeSpan.FromMinutes(15).TimeFrame() |
Исполнение
AtrPeriod = 14 задаёт ATR Уайлдера. При входе из нулевой позиции фиксируется расстояние ATR(сигнал) × StopLossAtr. Локальная рыночная защита по котировкам привязывает его к фактическому исполнению и может закрыть позицию между сигнальными свечами. Дальнейшее изменение ATR не двигает стоп. Ноль отключает защиту для нового запуска. Обратный сигнал закрывает весь фактический остаток; разворота и наращивания в том же решении нет.
Проверка
Исполнения проверяются только на имеющемся архиве BTC/TON. Явные тестовые отчёты, наблюдения опционов и данные прогрева проверяют механику и не выдаются за настоящую отчётность компаний или опционную историю. Сокращённые периоды, две корзины и мартовская годовая ребалансировка — отдельные тестовые профили, а не подтверждение торговли с неизменёнными долгосрочными defaults. Доходность не заявляется.
namespace StockSharp.Samples.Strategies;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Ecng.Common;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
/// <summary>Hull slope with observed implied volatility breakouts and frozen ATR protection.</summary>
public class HullMAWithImpliedVolatilityBreakoutStrategy : Strategy
{
private readonly StrategyParam<int> _hmaPeriod;
private readonly StrategyParam<int> _iVPeriod;
private readonly StrategyParam<decimal> _iVMultiplier;
private readonly StrategyParam<string> _iVHistoryFile;
private readonly StrategyParam<int> _atrPeriod;
private readonly StrategyParam<decimal> _stopLossAtr;
private readonly StrategyParam<DataType> _candleType;
private readonly Dictionary<DateTime, decimal> _observations = [];
private readonly Queue<decimal> _history = [];
private decimal? _previousHull;
private Order _signalOrder;
/// <summary>Hull moving average length.</summary>
public int HmaPeriod { get => _hmaPeriod.Value; set => _hmaPeriod.Value = value; }
/// <summary>Prior consecutive implied volatility observations used for the threshold.</summary>
public int IVPeriod { get => _iVPeriod.Value; set => _iVPeriod.Value = value; }
/// <summary>Sample standard deviation multiplier for the IV breakout.</summary>
public decimal IVMultiplier { get => _iVMultiplier.Value; set => _iVMultiplier.Value = value; }
/// <summary>CSV observed implied volatility in a consistent unit, keyed by candle UTC open time.</summary>
public string IVHistoryFile { get => _iVHistoryFile.Value; set => _iVHistoryFile.Value = value; }
/// <summary>Wilder ATR length for entry protection.</summary>
public int AtrPeriod { get => _atrPeriod.Value; set => _atrPeriod.Value = value; }
/// <summary>Entry-frozen native stop distance in ATR multiples; zero disables it for a fresh run.</summary>
public decimal StopLossAtr { get => _stopLossAtr.Value; set => _stopLossAtr.Value = value; }
/// <summary>Completed signal candle type.</summary>
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
/// <summary>Initialize the signal and protection parameters.</summary>
public HullMAWithImpliedVolatilityBreakoutStrategy()
{
_hmaPeriod = Param(nameof(HmaPeriod), 9).SetRange(2, 100000).SetOptimize(5, 15, 2)
.SetDisplay("HmaPeriod", "Hull moving average length", "Parameters");
_iVPeriod = Param(nameof(IVPeriod), 20).SetRange(2, 100000).SetOptimize(10, 30, 5)
.SetDisplay("IVPeriod", "Prior consecutive implied volatility observations used for the threshold", "Parameters");
_iVMultiplier = Param(nameof(IVMultiplier), 2m).SetGreaterThanZero().SetOptimize(1.5m, 3m, 0.5m)
.SetDisplay("IVMultiplier", "Sample standard deviation multiplier for the IV breakout", "Parameters");
_iVHistoryFile = Param<string>(nameof(IVHistoryFile)).SetRequired()
.SetDisplay("IVHistoryFile", "CSV observed implied volatility in a consistent unit, keyed by candle UTC open time", "Parameters");
_atrPeriod = Param(nameof(AtrPeriod), 14).SetGreaterThanZero()
.SetDisplay("AtrPeriod", "Wilder ATR length for entry protection", "Parameters");
_stopLossAtr = Param(nameof(StopLossAtr), 2m).SetRange(0m, decimal.MaxValue).SetOptimize(1m, 3m, 0.5m)
.SetDisplay("StopLossAtr", "Entry-frozen native stop distance in ATR multiples; zero disables it for a fresh run", "Parameters");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
.SetDisplay("CandleType", "Completed signal candle type", "Parameters");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities() => [(Security, CandleType)];
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_observations.Clear();
_history.Clear();
_previousHull = null;
_signalOrder = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
LoadObservations();
base.OnStarted2(time);
var signal = new HullMovingAverage { Length = HmaPeriod };
var atr = new AverageTrueRange { Length = AtrPeriod };
var subscription = SubscribeCandles(CandleType);
subscription.BindEx(signal, atr, ProcessCandle, allowEmpty: true).Start();
foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
{
var quote = new Subscription(DataType.Level1, Security);
quote.MarketData.BuildField = field;
SubscribeLevel1(quote).Start();
}
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, signal);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, IIndicatorValue signalValue, IIndicatorValue atrValue)
{
if (candle.State != CandleStates.Finished) return;
var ready = _observations.TryGetValue(candle.OpenTime, out var observation);
var low = false;
var high = false;
if (ready && _history.Count == IVPeriod)
{
var mean = _history.Average();
var deviation = (decimal)Math.Sqrt((double)(_history.Sum(x => (x - mean) * (x - mean)) / (_history.Count - 1)));
low = observation < mean - IVMultiplier * deviation;
high = observation > mean + IVMultiplier * deviation;
}
if (!ready) _history.Clear();
else
{
_history.Enqueue(observation);
while (_history.Count > IVPeriod) _history.Dequeue();
}
if (signalValue.IsEmpty || !signalValue.IsFormed) return;
var current = signalValue.GetValue<decimal>();
var up = _previousHull is decimal previousUp && current > previousUp;
var down = _previousHull is decimal previousDown && current < previousDown;
_previousHull = current;
if (!IsFormedAndOnlineAndAllowTrading() || _signalOrder is { State: not (OrderStates.Done or OrderStates.Failed) }) return;
if ((Position > 0m && down) || (Position < 0m && up))
{
Submit(Position > 0m ? Sides.Sell : Sides.Buy, Math.Abs(Position), "0344Exit");
return;
}
if (Position != 0m || atrValue.IsEmpty || !atrValue.IsFormed) return;
var atr = atrValue.GetValue<decimal>();
if (atr <= 0m) return;
Sides? side = up && high ? Sides.Buy : down && high ? Sides.Sell : null;
if (side is null) return;
if (StopLossAtr > 0m)
StartProtection(new Unit(), new Unit(atr * StopLossAtr, UnitTypes.Absolute), useMarketOrders: true, isLocalStop: true);
Submit(side.Value, Volume, "0344Entry");
}
private void Submit(Sides side, decimal quantity, string comment)
{
_signalOrder = CreateOrder(side, 0m, quantity);
_signalOrder.Type = OrderTypes.Market;
_signalOrder.Comment = comment;
RegisterOrder(_signalOrder);
}
private void LoadObservations()
{
if (string.IsNullOrWhiteSpace(IVHistoryFile))
throw new InvalidOperationException("IVHistoryFile is required.");
_observations.Clear();
var lines = File.ReadLines(IVHistoryFile).GetEnumerator();
using (lines)
{
if (!lines.MoveNext() || lines.Current.Trim() != "SecurityId,Time,Value")
throw new InvalidOperationException("Option observations must begin with SecurityId,Time,Value.");
while (lines.MoveNext())
{
if (string.IsNullOrWhiteSpace(lines.Current)) continue;
var row = lines.Current.Split(',');
if (row.Length != 3) throw new InvalidOperationException("An option observation must have three fields.");
if (!string.Equals(row[0].Trim(), Security.Id, StringComparison.OrdinalIgnoreCase)) continue;
var time = DateTime.ParseExact(row[1].Trim(), "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var value = decimal.Parse(row[2], CultureInfo.InvariantCulture);
if (value < 0m || !_observations.TryAdd(time, value))
throw new InvalidOperationException("Option observations must be nonnegative and unique per timestamp.");
}
}
if (_observations.Count == 0) throw new InvalidOperationException("No option observations match the execution security.");
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import DateTime, Decimal, Math, TimeSpan, InvalidOperationException
from System.Globalization import CultureInfo, DateTimeStyles
from System.IO import File
from StockSharp.Algo.Indicators import HullMovingAverage, AverageTrueRange
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Subscription
from StockSharp.Messages import CandleStates, DataType, Level1Fields, OrderStates, OrderTypes, Sides, Unit, UnitTypes
class hull_ma_implied_volatility_breakout_strategy(Strategy):
"""Observed option statistics filter, full signal exits and frozen native ATR protection."""
def __init__(self):
super(hull_ma_implied_volatility_breakout_strategy, self).__init__()
self._hma_period = self.Param("HmaPeriod", 9).SetRange(2, 100000).SetOptimize(5, 15, 2) \
.SetDisplay("HmaPeriod", "Hull moving average length", "Parameters")
self._iv_period = self.Param("IVPeriod", 20).SetRange(2, 100000).SetOptimize(10, 30, 5) \
.SetDisplay("IVPeriod", "Prior consecutive implied volatility observations used for the threshold", "Parameters")
self._iv_multiplier = self.Param("IVMultiplier", Decimal(2)).SetGreaterThanZero().SetOptimize(Decimal(1.5), Decimal(3), Decimal(0.5)) \
.SetDisplay("IVMultiplier", "Sample standard deviation multiplier for the IV breakout", "Parameters")
self._iv_history_file = self.Param[str]("IVHistoryFile", None).SetRequired() \
.SetDisplay("IVHistoryFile", "CSV observed implied volatility in a consistent unit, keyed by candle UTC open time", "Parameters")
self._atr_period = self.Param("AtrPeriod", 14).SetGreaterThanZero() \
.SetDisplay("AtrPeriod", "Wilder ATR length for entry protection", "Parameters")
self._stop_loss_atr = self.Param("StopLossAtr", Decimal(2)).SetRange(Decimal(0), Decimal.MaxValue).SetOptimize(Decimal(1), Decimal(3), Decimal(0.5)) \
.SetDisplay("StopLossAtr", "Entry-frozen native stop distance in ATR multiples; zero disables it for a fresh run", "Parameters")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(15))) \
.SetDisplay("CandleType", "Completed signal candle type", "Parameters")
self._observations = {}
self._history = []
self._previous_hull = None
self._signal_order = None
@property
def HmaPeriod(self):
return self._hma_period.Value
@HmaPeriod.setter
def HmaPeriod(self, value):
self._hma_period.Value = value
@property
def IVPeriod(self):
return self._iv_period.Value
@IVPeriod.setter
def IVPeriod(self, value):
self._iv_period.Value = value
@property
def IVMultiplier(self):
return self._iv_multiplier.Value
@IVMultiplier.setter
def IVMultiplier(self, value):
self._iv_multiplier.Value = value
@property
def IVHistoryFile(self):
return self._iv_history_file.Value
@IVHistoryFile.setter
def IVHistoryFile(self, value):
self._iv_history_file.Value = value
@property
def AtrPeriod(self):
return self._atr_period.Value
@AtrPeriod.setter
def AtrPeriod(self, value):
self._atr_period.Value = value
@property
def StopLossAtr(self):
return self._stop_loss_atr.Value
@StopLossAtr.setter
def StopLossAtr(self, value):
self._stop_loss_atr.Value = value
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
def GetWorkingSecurities(self):
return [(self.Security, self.CandleType)]
def OnReseted(self):
super(hull_ma_implied_volatility_breakout_strategy, self).OnReseted()
self._observations = {}
self._history = []
self._previous_hull = None
self._signal_order = None
def OnStarted2(self, time):
self._load_observations()
super(hull_ma_implied_volatility_breakout_strategy, self).OnStarted2(time)
signal = HullMovingAverage()
signal.Length = self.HmaPeriod
atr = AverageTrueRange()
atr.Length = self.AtrPeriod
subscription = self.SubscribeCandles(self.CandleType)
subscription.BindEx(signal, atr, self._process_candle, True).Start()
for field in (Level1Fields.BestBidPrice, Level1Fields.BestAskPrice):
quote = Subscription(DataType.Level1, self.Security)
quote.MarketData.BuildField = field
self.SubscribeLevel1(quote).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, signal)
self.DrawOwnTrades(area)
def _process_candle(self, candle, signal_value, atr_value):
if candle.State != CandleStates.Finished:
return
ready = candle.OpenTime in self._observations
low = False
high = False
if ready and len(self._history) == self.IVPeriod:
observation = self._observations[candle.OpenTime]
mean = sum(self._history, Decimal(0)) / Decimal(len(self._history))
variance = sum(((v - mean) * (v - mean) for v in self._history), Decimal(0)) / Decimal(len(self._history) - 1)
deviation = Decimal(Math.Sqrt(float(variance)))
low = observation < mean - self.IVMultiplier * deviation
high = observation > mean + self.IVMultiplier * deviation
if not ready:
self._history = []
else:
self._history.append(self._observations[candle.OpenTime])
self._history = self._history[-self.IVPeriod:]
if signal_value.IsEmpty or not signal_value.IsFormed:
return
current = signal_value.GetValue[Decimal](None)
up = self._previous_hull is not None and current > self._previous_hull
down = self._previous_hull is not None and current < self._previous_hull
self._previous_hull = current
if not self.IsFormedAndOnlineAndAllowTrading() or (self._signal_order is not None and self._signal_order.State not in (OrderStates.Done, OrderStates.Failed)):
return
if (self.Position > 0 and down) or (self.Position < 0 and up):
self._submit(Sides.Sell if self.Position > 0 else Sides.Buy, abs(self.Position), "0344Exit")
return
if self.Position != 0 or atr_value.IsEmpty or not atr_value.IsFormed:
return
atr = atr_value.GetValue[Decimal](None)
if atr <= 0:
return
side = Sides.Buy if up and high else Sides.Sell if down and high else None
if side is None:
return
if self.StopLossAtr > 0:
self.StartProtection(Unit(), Unit(atr * self.StopLossAtr, UnitTypes.Absolute), useMarketOrders=True, isLocalStop=True)
self._submit(side, self.Volume, "0344Entry")
def _submit(self, side, quantity, comment):
order = self.CreateOrder(side, Decimal(0), quantity)
order.Type = OrderTypes.Market
order.Comment = comment
self._signal_order = order
self.RegisterOrder(order)
def _load_observations(self):
if not self.IVHistoryFile:
raise InvalidOperationException("IVHistoryFile is required.")
self._observations = {}
lines = iter(File.ReadLines(self.IVHistoryFile))
if next(lines, "").strip() != "SecurityId,Time,Value":
raise InvalidOperationException("Option observations must begin with SecurityId,Time,Value.")
for line in lines:
if not line.strip():
continue
row = line.split(",")
if len(row) != 3:
raise InvalidOperationException("An option observation must have three fields.")
if row[0].strip().lower() != self.Security.Id.lower():
continue
time = DateTime.ParseExact(row[1].strip(), "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
value = Decimal.Parse(row[2], CultureInfo.InvariantCulture)
if value < 0 or time in self._observations:
raise InvalidOperationException("Option observations must be nonnegative and unique per timestamp.")
self._observations[time] = value
if not self._observations:
raise InvalidOperationException("No option observations match the execution security.")
def CreateClone(self):
return hull_ma_implied_volatility_breakout_strategy()