mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 03:28:05 +00:00
feat: add 8 new indicators with full integration
New indicators: - HWC (Holt-Winters Channel) — channels, 27 tests - VWMACD (Volume-Weighted MACD) — momentum, 38 tests - Squeeze Pro — oscillators, 69 tests - BW_MFI (Bill Williams MFI) — oscillators - DSTOCH (Double Stochastic) — oscillators - ATRSTOP (ATR Trailing Stop) — reversals - VSTOP (Volatility Stop) — reversals - Convexity (Beta Convexity) — statistics, 23 tests Integration: - Python bridge: Exports.cs, _bridge.py, wrapper modules - Documentation: _sidebar.md, _index.md pages, SPEC.md - All analyzer warnings fixed (MA0074, xUnit2013, S2699) Build: 0 warnings, 0 errors | Tests: 15,933 passed, 0 failed
This commit is contained in:
@@ -15,6 +15,7 @@ Channels define dynamic support and resistance. Upper band shows where price ten
|
||||
| [DC](dc/dc.md) | Donchian Channels | Highest high and lowest low over N periods. Turtle trading foundation. |
|
||||
| [DECAYCHANNEL](decaychannel/DecayChannel.md) | Decay Min-Max Channel | Exponentially decaying min-max channel. Half-life decay toward midpoint. |
|
||||
| [FCB](fcb/Fcb.md) | Fractal Chaos Bands | Tracks fractal highs and lows. Identifies chaos-based support/resistance. |
|
||||
| [HWC](hwc/Hwc.md) | Holt-Winters Channel | Triple exponential smoothing channel. Upper/middle/lower bands from level/trend/season. |
|
||||
| [JBANDS](jbands/Jbands.md) | Jurik Adaptive Envelope Bands | JMA's internal adaptive envelopes. Snap to extremes, decay toward price. |
|
||||
| [KC](kc/Kc.md) | Keltner Channel | EMA with ATR bands. Smoother than Bollinger. |
|
||||
| [MAENV](maenv/Maenv.md) | Moving Average Envelope | Fixed percentage bands around moving average. Simple but effective. |
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class HwcIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("Multiplier", sortIndex: 2, 0.1, 10.0, 0.1, 1)]
|
||||
public double Multiplier { get; set; } = 1.0;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput(sortIndex: 3)]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Hwc _hwc = null!;
|
||||
private readonly LineSeries _upperSeries;
|
||||
private readonly LineSeries _middleSeries;
|
||||
private readonly LineSeries _lowerSeries;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"HWC({Period},{Multiplier:F1}):{_sourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/channels/hwc/Hwc.Quantower.cs";
|
||||
|
||||
public HwcIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
_sourceName = Source.ToString();
|
||||
Name = "HWC - Holt-Winter Channel";
|
||||
Description = "Adaptive volatility channel based on Holt-Winters triple exponential smoothing";
|
||||
|
||||
_upperSeries = new LineSeries(name: "Upper", color: Color.Red, width: 1, style: LineStyle.Solid);
|
||||
_middleSeries = new LineSeries(name: "Middle", color: Color.Blue, width: 2, style: LineStyle.Solid);
|
||||
_lowerSeries = new LineSeries(name: "Lower", color: Color.Green, width: 1, style: LineStyle.Solid);
|
||||
|
||||
AddLineSeries(_upperSeries);
|
||||
AddLineSeries(_middleSeries);
|
||||
AddLineSeries(_lowerSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_hwc = new Hwc(Period, Multiplier);
|
||||
_sourceName = Source.ToString();
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double price = _priceSelector(item);
|
||||
TValue input = new(item.TimeLeft, price);
|
||||
|
||||
_hwc.Update(input, args.IsNewBar());
|
||||
|
||||
_upperSeries.SetValue(_hwc.Upper.Value, _hwc.IsHot, ShowColdValues);
|
||||
_middleSeries.SetValue(_hwc.Middle.Value, _hwc.IsHot, ShowColdValues);
|
||||
_lowerSeries.SetValue(_hwc.Lower.Value, _hwc.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// HWC: Holt-Winter Channel
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Volatility channel built around the Holt-Winters Moving Average (HWMA).
|
||||
/// Bands are placed at ±multiplier × √(filt) where filt is an EMA-smoothed
|
||||
/// squared forecast error, giving adaptive-width bands that widen with
|
||||
/// prediction error and contract when the HWMA tracks price well.
|
||||
///
|
||||
/// Calculation:
|
||||
/// <c>Middle = HWMA(source)</c>,
|
||||
/// <c>filt = α×(source − forecast)² + (1−α)×prev_filt</c>,
|
||||
/// <c>Upper = Middle + mult×√filt</c>,
|
||||
/// <c>Lower = Middle − mult×√filt</c>.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Hwc : AbstractBase
|
||||
{
|
||||
private readonly double _alpha;
|
||||
private readonly double _beta;
|
||||
private readonly double _gamma;
|
||||
private readonly double _decayAlpha;
|
||||
private readonly double _decayBeta;
|
||||
private readonly double _decayGamma;
|
||||
private readonly double _multiplier;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double F, double V, double A,
|
||||
double Filt, double LastValidValue,
|
||||
bool IsInitialized
|
||||
);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
public override bool IsHot => _state.IsInitialized;
|
||||
|
||||
/// <summary>Upper band = HWMA + mult × √filt</summary>
|
||||
public TValue Upper { get; private set; }
|
||||
|
||||
/// <summary>Middle band = HWMA output</summary>
|
||||
public TValue Middle { get; private set; }
|
||||
|
||||
/// <summary>Lower band = HWMA − mult × √filt</summary>
|
||||
public TValue Lower { get; private set; }
|
||||
|
||||
// ────────────────────────── constructors ──────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates HWC with auto-derived α/β/γ from period.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public Hwc(int period = 20, double multiplier = 1.0)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be > 0", nameof(period));
|
||||
}
|
||||
if (multiplier <= 0)
|
||||
{
|
||||
throw new ArgumentException("Multiplier must be > 0", nameof(multiplier));
|
||||
}
|
||||
|
||||
_alpha = 2.0 / (period + 1.0);
|
||||
_beta = 1.0 / period;
|
||||
_gamma = 1.0 / period;
|
||||
_decayAlpha = 1.0 - _alpha;
|
||||
_decayBeta = 1.0 - _beta;
|
||||
_decayGamma = 1.0 - _gamma;
|
||||
_multiplier = multiplier;
|
||||
|
||||
WarmupPeriod = period;
|
||||
Name = $"Hwc({period},{multiplier:F1})";
|
||||
_state = new State(double.NaN, 0, 0, 0, double.NaN, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates HWC with explicit smoothing factors.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public Hwc(double alpha, double beta, double gamma, double multiplier = 1.0)
|
||||
{
|
||||
if (alpha is <= 0 or > 1)
|
||||
{
|
||||
throw new ArgumentException("Alpha must be (0,1]", nameof(alpha));
|
||||
}
|
||||
if (beta is < 0 or > 1)
|
||||
{
|
||||
throw new ArgumentException("Beta must be [0,1]", nameof(beta));
|
||||
}
|
||||
if (gamma is < 0 or > 1)
|
||||
{
|
||||
throw new ArgumentException("Gamma must be [0,1]", nameof(gamma));
|
||||
}
|
||||
if (multiplier <= 0)
|
||||
{
|
||||
throw new ArgumentException("Multiplier must be > 0", nameof(multiplier));
|
||||
}
|
||||
|
||||
_alpha = alpha;
|
||||
_beta = beta;
|
||||
_gamma = gamma;
|
||||
_decayAlpha = 1.0 - alpha;
|
||||
_decayBeta = 1.0 - beta;
|
||||
_decayGamma = 1.0 - gamma;
|
||||
_multiplier = multiplier;
|
||||
|
||||
int effectivePeriod = Math.Max((int)(2.0 / alpha - 1.0), 1);
|
||||
WarmupPeriod = effectivePeriod;
|
||||
Name = $"Hwc({alpha:F3},{beta:F3},{gamma:F3},{multiplier:F1})";
|
||||
_state = new State(double.NaN, 0, 0, 0, double.NaN, false);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public Hwc(ITValuePublisher source, int period = 20, double multiplier = 1.0)
|
||||
: this(period, multiplier)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double GetValidValue(double input)
|
||||
{
|
||||
if (double.IsFinite(input))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
return _state.IsInitialized ? _state.LastValidValue : double.NaN;
|
||||
}
|
||||
|
||||
// ────────────────────────── core Update ──────────────────────────
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
double val = GetValidValue(input.Value);
|
||||
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
Upper = Middle = Lower = Last;
|
||||
PubEvent(Last);
|
||||
return Last;
|
||||
}
|
||||
|
||||
_state = _state with { LastValidValue = val };
|
||||
|
||||
double result;
|
||||
double filtVal;
|
||||
|
||||
if (!_state.IsInitialized)
|
||||
{
|
||||
_state = _state with { F = val, V = 0, A = 0, Filt = 0, IsInitialized = true };
|
||||
result = val;
|
||||
filtVal = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double prevF = _state.F;
|
||||
double prevV = _state.V;
|
||||
double prevA = _state.A;
|
||||
|
||||
// HWMA: F = α×src + (1−α)×(prevF + prevV + 0.5×prevA)
|
||||
double forecast = prevF + prevV + 0.5 * prevA;
|
||||
double newF = Math.FusedMultiplyAdd(forecast, _decayAlpha, _alpha * val);
|
||||
|
||||
// V = β×(F − prevF) + (1−β)×(prevV + prevA)
|
||||
double newV = Math.FusedMultiplyAdd(prevV + prevA, _decayBeta, _beta * (newF - prevF));
|
||||
|
||||
// A = γ×(V − prevV) + (1−γ)×prevA
|
||||
double newA = Math.FusedMultiplyAdd(prevA, _decayGamma, _gamma * (newV - prevV));
|
||||
|
||||
result = newF + newV + 0.5 * newA;
|
||||
|
||||
// Adaptive volatility filter: filt = α×(src − forecast)² + (1−α)×prevFilt
|
||||
double err = val - forecast;
|
||||
filtVal = Math.FusedMultiplyAdd(err * err, _alpha, _state.Filt * _decayAlpha);
|
||||
|
||||
_state = _state with { F = newF, V = newV, A = newA, Filt = filtVal };
|
||||
}
|
||||
|
||||
double band = _multiplier * Math.Sqrt(filtVal);
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
Middle = Last;
|
||||
Upper = new TValue(input.Time, result + band);
|
||||
Lower = new TValue(input.Time, result - band);
|
||||
|
||||
PubEvent(Last);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// ────────────────────────── Update(TSeries) ──────────────────────────
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
TSeries middleSeries = new(capacity: len);
|
||||
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
TValue input = source[i];
|
||||
Update(input, isNew: true);
|
||||
middleSeries.Add(input.Time, Middle.Value, isNew: true);
|
||||
}
|
||||
|
||||
return middleSeries;
|
||||
}
|
||||
|
||||
// ────────────────────────── Prime ──────────────────────────
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
step ??= TimeSpan.FromSeconds(1);
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(startTime + i * step.Value, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── Reset ──────────────────────────
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override void Reset()
|
||||
{
|
||||
_state = new State(double.NaN, 0, 0, 0, double.NaN, false);
|
||||
_p_state = _state;
|
||||
Upper = Middle = Lower = default;
|
||||
}
|
||||
|
||||
// ────────────────────────── static Batch (Span) ──────────────────────────
|
||||
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> source,
|
||||
Span<double> upper, Span<double> middle, Span<double> lower,
|
||||
int period = 20, double multiplier = 1.0)
|
||||
{
|
||||
int n = source.Length;
|
||||
if (n != upper.Length || n != middle.Length || n != lower.Length)
|
||||
{
|
||||
throw new ArgumentException("All spans must have the same length", nameof(source));
|
||||
}
|
||||
|
||||
double alpha = 2.0 / (period + 1.0);
|
||||
double beta = 1.0 / period;
|
||||
double gamma = 1.0 / period;
|
||||
double dA = 1.0 - alpha;
|
||||
double dB = 1.0 - beta;
|
||||
double dG = 1.0 - gamma;
|
||||
|
||||
double f = double.NaN, v = 0, a = 0, filt = 0;
|
||||
bool init = false;
|
||||
double lastValid = double.NaN;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = double.IsFinite(lastValid) ? lastValid : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
double result;
|
||||
if (!init)
|
||||
{
|
||||
f = val; v = 0; a = 0; filt = 0;
|
||||
init = true;
|
||||
result = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
double forecast = f + v + 0.5 * a;
|
||||
double newF = Math.FusedMultiplyAdd(forecast, dA, alpha * val);
|
||||
double newV = Math.FusedMultiplyAdd(v + a, dB, beta * (newF - f));
|
||||
double newA = Math.FusedMultiplyAdd(a, dG, gamma * (newV - v));
|
||||
result = newF + newV + 0.5 * newA;
|
||||
|
||||
double err = val - forecast;
|
||||
filt = Math.FusedMultiplyAdd(err * err, alpha, filt * dA);
|
||||
|
||||
f = newF; v = newV; a = newA;
|
||||
}
|
||||
|
||||
double band = multiplier * Math.Sqrt(filt);
|
||||
middle[i] = result;
|
||||
upper[i] = result + band;
|
||||
lower[i] = result - band;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── static Batch (TSeries) ──────────────────────────
|
||||
|
||||
public static (TSeries Upper, TSeries Middle, TSeries Lower) Batch(
|
||||
TSeries source, int period = 20, double multiplier = 1.0)
|
||||
{
|
||||
var ind = new Hwc(period, multiplier);
|
||||
int len = source.Count;
|
||||
if (len == 0)
|
||||
{
|
||||
return ([], [], []);
|
||||
}
|
||||
|
||||
var tU = new List<long>(len); var vU = new List<double>(len);
|
||||
var tM = new List<long>(len); var vM = new List<double>(len);
|
||||
var tL = new List<long>(len); var vL = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(tU, len); CollectionsMarshal.SetCount(vU, len);
|
||||
CollectionsMarshal.SetCount(tM, len); CollectionsMarshal.SetCount(vM, len);
|
||||
CollectionsMarshal.SetCount(tL, len); CollectionsMarshal.SetCount(vL, len);
|
||||
|
||||
var tuSpan = CollectionsMarshal.AsSpan(tU); var vuSpan = CollectionsMarshal.AsSpan(vU);
|
||||
var tmSpan = CollectionsMarshal.AsSpan(tM); var vmSpan = CollectionsMarshal.AsSpan(vM);
|
||||
var tlSpan = CollectionsMarshal.AsSpan(tL); var vlSpan = CollectionsMarshal.AsSpan(vL);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
ind.Update(source[i], isNew: true);
|
||||
long time = source[i].Time;
|
||||
tuSpan[i] = time; vuSpan[i] = ind.Upper.Value;
|
||||
tmSpan[i] = time; vmSpan[i] = ind.Middle.Value;
|
||||
tlSpan[i] = time; vlSpan[i] = ind.Lower.Value;
|
||||
}
|
||||
|
||||
return (new TSeries(tU, vU), new TSeries(tM, vM), new TSeries(tL, vL));
|
||||
}
|
||||
|
||||
public static ((TSeries Upper, TSeries Middle, TSeries Lower) Results, Hwc Indicator) Calculate(
|
||||
TSeries source, int period = 20, double multiplier = 1.0)
|
||||
{
|
||||
var ind = new Hwc(period, multiplier);
|
||||
int len = source.Count;
|
||||
if (len == 0)
|
||||
{
|
||||
return (([], [], []), ind);
|
||||
}
|
||||
|
||||
var tU = new List<long>(len); var vU = new List<double>(len);
|
||||
var tM = new List<long>(len); var vM = new List<double>(len);
|
||||
var tL = new List<long>(len); var vL = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(tU, len); CollectionsMarshal.SetCount(vU, len);
|
||||
CollectionsMarshal.SetCount(tM, len); CollectionsMarshal.SetCount(vM, len);
|
||||
CollectionsMarshal.SetCount(tL, len); CollectionsMarshal.SetCount(vL, len);
|
||||
|
||||
var tuSpan = CollectionsMarshal.AsSpan(tU); var vuSpan = CollectionsMarshal.AsSpan(vU);
|
||||
var tmSpan = CollectionsMarshal.AsSpan(tM); var vmSpan = CollectionsMarshal.AsSpan(vM);
|
||||
var tlSpan = CollectionsMarshal.AsSpan(tL); var vlSpan = CollectionsMarshal.AsSpan(vL);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
ind.Update(source[i], isNew: true);
|
||||
long time = source[i].Time;
|
||||
tuSpan[i] = time; vuSpan[i] = ind.Upper.Value;
|
||||
tmSpan[i] = time; vmSpan[i] = ind.Middle.Value;
|
||||
tlSpan[i] = time; vlSpan[i] = ind.Lower.Value;
|
||||
}
|
||||
|
||||
return ((new TSeries(tU, vU), new TSeries(tM, vM), new TSeries(tL, vL)), ind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class HwcIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void HwcIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new HwcIndicator();
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(1.0, indicator.Multiplier);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Contains("HWC", indicator.Name, StringComparison.Ordinal);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new HwcIndicator();
|
||||
Assert.Equal(0, HwcIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new HwcIndicator { Period = 20, Multiplier = 1.5 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("HWC", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new HwcIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Hwc", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_Initialize_CreatesThreeLineSeries()
|
||||
{
|
||||
var indicator = new HwcIndicator { Period = 20, Multiplier = 1.0 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Upper + Middle + Lower
|
||||
Assert.Equal(3, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_SeparateWindow_False()
|
||||
{
|
||||
var indicator = new HwcIndicator();
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HwcIndicator_CustomParams_ShortName()
|
||||
{
|
||||
var indicator = new HwcIndicator { Period = 10, Multiplier = 2.0 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("2.0", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class HwcTests
|
||||
{
|
||||
private static TSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = new TSeries(capacity: count);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
series.Add(bars[i].Time, bars[i].Close, isNew: true);
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
// === A) Constructor validation ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(period: -1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidMultiplier_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(period: 20, multiplier: 0));
|
||||
Assert.Equal("multiplier", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeMultiplier_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(period: 20, multiplier: -1.0));
|
||||
Assert.Equal("multiplier", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParams()
|
||||
{
|
||||
var ind = new Hwc();
|
||||
Assert.Equal("Hwc(20,1.0)", ind.Name);
|
||||
Assert.Equal(20, ind.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParams()
|
||||
{
|
||||
var ind = new Hwc(period: 10, multiplier: 2.0);
|
||||
Assert.Equal("Hwc(10,2.0)", ind.Name);
|
||||
Assert.Equal(10, ind.WarmupPeriod);
|
||||
}
|
||||
|
||||
// === A2) Alpha/Beta/Gamma constructor ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Alpha_InvalidLow_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(alpha: 0, beta: 0.1, gamma: 0.1));
|
||||
Assert.Equal("alpha", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Alpha_InvalidHigh_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(alpha: 1.1, beta: 0.1, gamma: 0.1));
|
||||
Assert.Equal("alpha", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Beta_InvalidNeg_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(alpha: 0.5, beta: -0.1, gamma: 0.1));
|
||||
Assert.Equal("beta", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Gamma_InvalidHigh_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Hwc(alpha: 0.5, beta: 0.1, gamma: 1.1));
|
||||
Assert.Equal("gamma", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AlphaBetaGamma_ValidParams()
|
||||
{
|
||||
var ind = new Hwc(alpha: 0.1, beta: 0.05, gamma: 0.05, multiplier: 2.0);
|
||||
Assert.Contains("Hwc(", ind.Name, StringComparison.Ordinal);
|
||||
Assert.True(ind.WarmupPeriod >= 1);
|
||||
}
|
||||
|
||||
// === B) Basic calculation ===
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
TValue result = ind.Update(input);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Upper_Middle_Lower_Accessible()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i));
|
||||
}
|
||||
Assert.True(double.IsFinite(ind.Upper.Value));
|
||||
Assert.True(double.IsFinite(ind.Middle.Value));
|
||||
Assert.True(double.IsFinite(ind.Lower.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upper_GreaterEqual_Middle_GreaterEqual_Lower()
|
||||
{
|
||||
var ind = new Hwc(period: 10, multiplier: 1.0);
|
||||
var series = GenerateSeries(50);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
Assert.True(ind.Upper.Value >= ind.Middle.Value);
|
||||
Assert.True(ind.Middle.Value >= ind.Lower.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantInput_BandsCollapse()
|
||||
{
|
||||
var ind = new Hwc(period: 5, multiplier: 1.0);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
|
||||
}
|
||||
// With constant input, forecast error = 0, so upper = middle = lower
|
||||
Assert.Equal(ind.Middle.Value, ind.Upper.Value, precision: 8);
|
||||
Assert.Equal(ind.Middle.Value, ind.Lower.Value, precision: 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantInput_MiddleEqualsInput()
|
||||
{
|
||||
var ind = new Hwc(period: 5, multiplier: 1.0);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
|
||||
}
|
||||
// HWMA of constant series should converge to the constant value
|
||||
Assert.Equal(100.0, ind.Middle.Value, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Volatile_Data_Wider_Bands()
|
||||
{
|
||||
var indCalm = new Hwc(period: 10, multiplier: 1.0);
|
||||
var indVolatile = new Hwc(period: 10, multiplier: 1.0);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
// Low volatility: small oscillation
|
||||
indCalm.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + Math.Sin(i * 0.1)));
|
||||
// High volatility: large oscillation
|
||||
indVolatile.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + Math.Sin(i * 0.1) * 20));
|
||||
}
|
||||
|
||||
double widthCalm = indCalm.Upper.Value - indCalm.Lower.Value;
|
||||
double widthVolatile = indVolatile.Upper.Value - indVolatile.Lower.Value;
|
||||
|
||||
Assert.True(widthVolatile > widthCalm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiplier_Scales_Bands()
|
||||
{
|
||||
var ind1 = new Hwc(period: 10, multiplier: 1.0);
|
||||
var ind2 = new Hwc(period: 10, multiplier: 2.0);
|
||||
var series = GenerateSeries(50);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind1.Update(series[i], isNew: true);
|
||||
ind2.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
double width1 = ind1.Upper.Value - ind1.Lower.Value;
|
||||
double width2 = ind2.Upper.Value - ind2.Lower.Value;
|
||||
|
||||
// width2 should be ~2x width1
|
||||
Assert.Equal(2.0, width2 / width1, precision: 6);
|
||||
}
|
||||
|
||||
// === C) State + bar correction ===
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_Advances_State()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var series = GenerateSeries(10);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
Assert.True(ind.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var series = GenerateSeries(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
ind.Update(series[9], isNew: true);
|
||||
double midAfterNew = ind.Middle.Value;
|
||||
|
||||
var corrected = new TValue(series[9].Time, 999.0);
|
||||
ind.Update(corrected, isNew: false);
|
||||
double midAfterCorrection = ind.Middle.Value;
|
||||
|
||||
Assert.NotEqual(midAfterNew, midAfterCorrection, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Idempotent()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var series = GenerateSeries(10);
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
ind.Update(series[9], isNew: true);
|
||||
double baseline = ind.Middle.Value;
|
||||
|
||||
ind.Update(series[9], isNew: false);
|
||||
Assert.Equal(baseline, ind.Middle.Value, precision: 10);
|
||||
}
|
||||
|
||||
// === D) Reset ===
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestoresInitialState()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var series = GenerateSeries(20);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
Assert.True(ind.IsHot);
|
||||
|
||||
ind.Reset();
|
||||
Assert.False(ind.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ThenUpdate_Identical()
|
||||
{
|
||||
var ind1 = new Hwc(period: 10, multiplier: 1.5);
|
||||
var ind2 = new Hwc(period: 10, multiplier: 1.5);
|
||||
var series = GenerateSeries(30);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind1.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
ind1.Reset();
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind1.Update(series[i], isNew: true);
|
||||
ind2.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(ind2.Middle.Value, ind1.Middle.Value, precision: 10);
|
||||
Assert.Equal(ind2.Upper.Value, ind1.Upper.Value, precision: 10);
|
||||
Assert.Equal(ind2.Lower.Value, ind1.Lower.Value, precision: 10);
|
||||
}
|
||||
|
||||
// === E) Series / Batch ===
|
||||
|
||||
[Fact]
|
||||
public void Update_TSeries_ReturnsCorrectLength()
|
||||
{
|
||||
var ind = new Hwc(period: 10);
|
||||
var series = GenerateSeries(50);
|
||||
TSeries result = ind.Update(series);
|
||||
Assert.Equal(50, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_TSeries_ReturnsThreeSeries()
|
||||
{
|
||||
var series = GenerateSeries(50);
|
||||
var (upper, middle, lower) = Hwc.Batch(series, period: 10, multiplier: 1.0);
|
||||
Assert.Equal(50, upper.Count);
|
||||
Assert.Equal(50, middle.Count);
|
||||
Assert.Equal(50, lower.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MatchesStreaming()
|
||||
{
|
||||
var series = GenerateSeries(50);
|
||||
double[] source = new double[series.Count];
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
source[i] = series[i].Value;
|
||||
}
|
||||
|
||||
double[] upper = new double[series.Count];
|
||||
double[] middle = new double[series.Count];
|
||||
double[] lower = new double[series.Count];
|
||||
|
||||
Hwc.Batch(source, upper, middle, lower, period: 10, multiplier: 1.5);
|
||||
|
||||
// Compare with streaming
|
||||
var ind = new Hwc(period: 10, multiplier: 1.5);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(ind.Middle.Value, middle[^1], precision: 10);
|
||||
Assert.Equal(ind.Upper.Value, upper[^1], precision: 10);
|
||||
Assert.Equal(ind.Lower.Value, lower[^1], precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var series = GenerateSeries(50);
|
||||
var (results, indicator) = Hwc.Calculate(series, period: 10, multiplier: 1.0);
|
||||
Assert.Equal(50, results.Upper.Count);
|
||||
Assert.Equal(50, results.Middle.Count);
|
||||
Assert.Equal(50, results.Lower.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsState()
|
||||
{
|
||||
var ind = new Hwc(period: 10);
|
||||
double[] data = new double[50];
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
data[i] = 100.0 + i;
|
||||
}
|
||||
ind.Prime(data);
|
||||
Assert.True(ind.IsHot);
|
||||
}
|
||||
|
||||
// === F) NaN handling ===
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_ProducesNaN_WhenNotInitialized()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var result = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValid_WhenInitialized()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
|
||||
}
|
||||
// Now send NaN — should use lastValidValue internally
|
||||
var result = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(10), double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
// === G) Edge cases ===
|
||||
|
||||
[Fact]
|
||||
public void SingleInput_ProducesFiniteOutput()
|
||||
{
|
||||
var ind = new Hwc(period: 5);
|
||||
var result = ind.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.Equal(100.0, result.Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeDataset_ProducesFiniteOutput()
|
||||
{
|
||||
var ind = new Hwc();
|
||||
var series = GenerateSeries(10_000);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
ind.Update(series[i], isNew: true);
|
||||
}
|
||||
Assert.True(ind.IsHot);
|
||||
Assert.True(double.IsFinite(ind.Middle.Value));
|
||||
Assert.True(double.IsFinite(ind.Upper.Value));
|
||||
Assert.True(double.IsFinite(ind.Lower.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptySeries_ReturnsEmpty()
|
||||
{
|
||||
var series = new TSeries();
|
||||
var (upper, middle, lower) = Hwc.Batch(series);
|
||||
Assert.Empty(upper);
|
||||
Assert.Empty(middle);
|
||||
Assert.Empty(lower);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_LengthMismatch_Throws()
|
||||
{
|
||||
double[] source = new double[10];
|
||||
double[] upper = new double[5]; // mismatch!
|
||||
double[] middle = new double[10];
|
||||
double[] lower = new double[10];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Hwc.Batch(source, upper, middle, lower));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TSeries_Null_Throws()
|
||||
{
|
||||
var ind = new Hwc();
|
||||
Assert.Throws<ArgumentNullException>(() => ind.Update((TSeries)null!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user