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:
Miha Kralj
2026-03-17 08:35:29 -07:00
parent 6f0a339c9b
commit 15f4bb90f3
71 changed files with 10194 additions and 44 deletions
+1
View File
@@ -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. |
+74
View File
@@ -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);
}
}
+388
View File
@@ -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);
}
}
+448
View File
@@ -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!));
}
}