Refactor documentation links in numerics, oscillators, reversals, and statistics modules to use relative paths; update Bias class to handle division by zero more robustly; remove obsolete CUMMEAN Pine script; enhance trend indicators documentation; add Visual Studio Code workspace configuration.

This commit is contained in:
Miha Kralj
2026-02-04 11:43:59 -08:00
parent c034cbd5e5
commit 3e854eac3f
60 changed files with 9944 additions and 2641 deletions
+341
View File
@@ -0,0 +1,341 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Quantower.Tests;
public class HomodIndicatorTests
{
[Fact]
public void HomodIndicator_Constructor_SetsDefaults()
{
var indicator = new HomodIndicator();
Assert.Equal(6.0, indicator.MinPeriod);
Assert.Equal(50.0, indicator.MaxPeriod);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("HOMOD - Homodyne Discriminator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HomodIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new HomodIndicator();
Assert.Equal(0, HomodIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HomodIndicator_ShortName_IncludesPeriods()
{
var indicator = new HomodIndicator { MinPeriod = 8.0, MaxPeriod = 60.0 };
Assert.True(indicator.ShortName.Contains("HOMOD", StringComparison.Ordinal));
Assert.True(indicator.ShortName.Contains("8", StringComparison.Ordinal));
Assert.True(indicator.ShortName.Contains("60", StringComparison.Ordinal));
}
[Fact]
public void HomodIndicator_Initialize_CreatesInternalHomod()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (Cycle only)
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void HomodIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void HomodIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void HomodIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
// Should not throw an exception
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
// Assert that the indicator still exists
Assert.NotNull(indicator);
}
[Fact]
public void HomodIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 105, 103, 107, 110, 108, 112, 115, 113 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
}
[Fact]
public void HomodIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void HomodIndicator_MinPeriod_CanBeChanged()
{
var indicator = new HomodIndicator { MinPeriod = 6.0 };
Assert.Equal(6.0, indicator.MinPeriod);
indicator.MinPeriod = 10.0;
Assert.Equal(10.0, indicator.MinPeriod);
}
[Fact]
public void HomodIndicator_MaxPeriod_CanBeChanged()
{
var indicator = new HomodIndicator { MaxPeriod = 50.0 };
Assert.Equal(50.0, indicator.MaxPeriod);
indicator.MaxPeriod = 100.0;
Assert.Equal(100.0, indicator.MaxPeriod);
}
[Fact]
public void HomodIndicator_Source_CanBeChanged()
{
var indicator = new HomodIndicator { Source = SourceType.Close };
Assert.Equal(SourceType.Close, indicator.Source);
indicator.Source = SourceType.Open;
Assert.Equal(SourceType.Open, indicator.Source);
}
[Fact]
public void HomodIndicator_ShowColdValues_CanBeChanged()
{
var indicator = new HomodIndicator { ShowColdValues = true };
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
}
[Fact]
public void HomodIndicator_ShortName_UpdatesWhenPeriodsChange()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
string initialName = indicator.ShortName;
Assert.True(initialName.Contains("6", StringComparison.Ordinal));
Assert.True(initialName.Contains("50", StringComparison.Ordinal));
indicator.MinPeriod = 10.0;
indicator.MaxPeriod = 60.0;
string updatedName = indicator.ShortName;
Assert.True(updatedName.Contains("10", StringComparison.Ordinal));
Assert.True(updatedName.Contains("60", StringComparison.Ordinal));
}
[Fact]
public void HomodIndicator_ProcessUpdate_IgnoresNonBarUpdates()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process historical bar first
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Process other update reasons - should not throw
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.NotNull(indicator);
}
[Fact]
public void HomodIndicator_CycleSeries_HasCorrectProperties()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var lineSeries = indicator.LinesSeries[0];
Assert.Equal("Cycle", lineSeries.Name);
Assert.Equal(2, lineSeries.Width);
Assert.Equal(LineStyle.Solid, lineSeries.Style);
}
[Fact]
public void HomodIndicator_DifferentPeriodRanges_Work()
{
var periodRanges = new[] { (6.0, 50.0), (8.0, 60.0), (5.0, 30.0), (10.0, 100.0) };
foreach (var (minPeriod, maxPeriod) in periodRanges)
{
var indicator = new HomodIndicator { MinPeriod = minPeriod, MaxPeriod = maxPeriod };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add enough bars
int numBars = (int)maxPeriod + 50;
for (int i = 0; i < numBars; i++)
{
double close = 100 + (i % 10);
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Last value should be finite
double cycleValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(cycleValue), $"Period range ({minPeriod},{maxPeriod}) should produce finite value");
}
}
[Fact]
public void HomodIndicator_SineWave_DetectsCycle()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
const int knownPeriod = 20;
// Generate sine wave pattern
for (int i = 0; i < 200; i++)
{
double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / knownPeriod);
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Cycle value should be in valid range
double cycleValue = indicator.LinesSeries[0].GetValue(0);
Assert.InRange(cycleValue, 6.0, 50.0);
}
[Fact]
public void HomodIndicator_ConstantInput_ProducesStableOutput()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
const double constantPrice = 100.0;
for (int i = 0; i < 100; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), constantPrice, constantPrice, constantPrice, constantPrice);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Should produce finite values even with constant input
double cycleValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(cycleValue));
}
[Fact]
public void HomodIndicator_TrendingInput_ProducesFiniteOutput()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 100; i++)
{
double price = 100.0 + i * 0.5; // Trending up
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Should produce finite values with trending input
double cycleValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(cycleValue));
}
[Fact]
public void HomodIndicator_VolatileInput_ProducesFiniteOutput()
{
var indicator = new HomodIndicator { MinPeriod = 6.0, MaxPeriod = 50.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 100; i++)
{
double price = 100.0 + (i % 2 == 0 ? 10.0 : -10.0); // Volatile swings
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Should produce finite values with volatile input
double cycleValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(cycleValue));
}
}
+68
View File
@@ -0,0 +1,68 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class HomodIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Min Period", sortIndex: 1, 3.0, 100.0, 0.5, 1)]
public double MinPeriod { get; set; } = 6.0;
[InputParameter("Max Period", sortIndex: 2, 4.0, 200.0, 0.5, 1)]
public double MaxPeriod { get; set; } = 50.0;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Homod _homod = null!;
private readonly LineSeries _cycleSeries;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"HOMOD ({MinPeriod},{MaxPeriod})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/homod/Homod.Quantower.cs";
public HomodIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "HOMOD - Homodyne Discriminator";
Description = "Ehlers' Homodyne Discriminator estimates the dominant cycle period using homodyne multiplication and phase angle measurement";
_cycleSeries = new LineSeries(name: "Cycle", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
AddLineSeries(_cycleSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_homod = new Homod(MinPeriod, MaxPeriod);
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
{
return;
}
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
double value = _priceSelector(item);
var time = this.HistoricalData.Time();
var input = new TValue(time, value);
TValue result = _homod.Update(input, args.IsNewBar());
_cycleSeries.SetValue(result.Value, _homod.IsHot, ShowColdValues);
}
}
+485
View File
@@ -0,0 +1,485 @@
using Xunit;
namespace QuanTAlib.Tests;
public class HomodTests
{
private const double Tolerance = 1e-9;
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsProperties()
{
var homod = new Homod();
Assert.Equal("Homod(6,50)", homod.Name);
Assert.Equal(100, homod.WarmupPeriod); // maxPeriod * 2
Assert.False(homod.IsHot);
}
[Fact]
public void Constructor_CustomParameters_SetsProperties()
{
var homod = new Homod(minPeriod: 8, maxPeriod: 40);
Assert.Equal("Homod(8,40)", homod.Name);
Assert.Equal(80, homod.WarmupPeriod);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-5)]
public void Constructor_InvalidMinPeriod_ThrowsArgumentOutOfRange(double minPeriod)
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Homod(minPeriod, 50));
Assert.Equal("minPeriod", ex.ParamName);
}
[Theory]
[InlineData(10, 10)]
[InlineData(10, 5)]
[InlineData(20, 15)]
public void Constructor_MaxPeriodNotGreaterThanMin_ThrowsArgumentOutOfRange(double minPeriod, double maxPeriod)
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Homod(minPeriod, maxPeriod));
Assert.Equal("maxPeriod", ex.ParamName);
}
[Fact]
public void Constructor_WithNullSource_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new Homod(null!, 6, 50));
}
[Fact]
public void Constructor_WithValidSource_Subscribes()
{
var source = new TSeries();
var homod = new Homod(source, 6, 50);
source.Add(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(default, homod.Last);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_ReturnsValidTValue()
{
var homod = new Homod(6, 50);
var result = homod.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_AfterWarmup_IsHotTrue()
{
var homod = new Homod(6, 50);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
homod.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(homod.IsHot);
}
[Fact]
public void Update_DominantCycle_WithinRange()
{
var homod = new Homod(6, 50);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
homod.Update(new TValue(bar.Time, bar.Close));
}
// Dominant cycle should be within the specified range
Assert.InRange(homod.DominantCycle, 6, 50);
}
[Fact]
public void Update_InitialValue_NearMidpoint()
{
var homod = new Homod(6, 50);
// First update should return near initial period (15)
var result = homod.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(result.Value >= 6 && result.Value <= 50);
}
#endregion
#region Bar Correction Tests
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var homod = new Homod(6, 50);
homod.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
var first = homod.Last.Value;
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 110.0), isNew: true);
var second = homod.Last.Value;
Assert.True(double.IsFinite(first) && double.IsFinite(second));
}
[Fact]
public void Update_IsNewFalse_ReplacesCurrentBar()
{
var homod = new Homod(6, 50);
// Build some history
for (int i = 0; i < 100; i++)
{
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10), isNew: true);
}
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 110.0), isNew: true);
var beforeCorrection = homod.Last.Value;
// Correct the bar with a different value
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 90.0), isNew: false);
var afterCorrection = homod.Last.Value;
Assert.True(double.IsFinite(beforeCorrection) && double.IsFinite(afterCorrection));
}
[Fact]
public void Update_MultipleCorrections_RestoresToSnapshot()
{
var homod = new Homod(6, 50);
// Build some history
for (int i = 0; i < 100; i++)
{
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
// Add a new bar
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 150.0), isNew: true);
var originalValue = homod.Last.Value;
// Correct multiple times
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 160.0), isNew: false);
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 140.0), isNew: false);
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 150.0), isNew: false);
var restoredValue = homod.Last.Value;
Assert.Equal(originalValue, restoredValue, Tolerance);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var homod = new Homod(6, 50);
for (int i = 0; i < 200; i++)
{
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(homod.IsHot);
homod.Reset();
Assert.False(homod.IsHot);
Assert.Equal(default, homod.Last);
}
[Fact]
public void Reset_AllowsReuse()
{
var homod = new Homod(6, 50);
// First run
for (int i = 0; i < 200; i++)
{
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
}
var firstResult = homod.Last.Value;
homod.Reset();
// Second run with same data
for (int i = 0; i < 200; i++)
{
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
}
var secondResult = homod.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region NaN/Infinity Handling Tests
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var homod = new Homod(6, 50);
homod.Update(new TValue(DateTime.UtcNow, 100.0));
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NaN));
Assert.True(double.IsFinite(homod.Last.Value));
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var homod = new Homod(6, 50);
homod.Update(new TValue(DateTime.UtcNow, 100.0));
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.PositiveInfinity));
Assert.True(double.IsFinite(homod.Last.Value));
}
[Fact]
public void Update_NegativeInfinity_UsesLastValidValue()
{
var homod = new Homod(6, 50);
homod.Update(new TValue(DateTime.UtcNow, 100.0));
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NegativeInfinity));
Assert.True(double.IsFinite(homod.Last.Value));
}
#endregion
#region Consistency Tests
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(999)]
public void Update_StreamingMatchesBatch(int seed)
{
const double minPeriod = 6;
const double maxPeriod = 50;
const int dataLen = 200;
var gbm = new GBM(seed: seed);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Homod(minPeriod, maxPeriod);
foreach (var bar in bars)
{
streaming.Update(new TValue(bar.Time, bar.Close));
}
// Batch via TSeries
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
var batch = Homod.Calculate(tSeries, minPeriod, maxPeriod);
// Compare last values
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
}
[Fact]
public void Batch_MatchesStreaming()
{
const double minPeriod = 6;
const double maxPeriod = 50;
const int dataLen = 200;
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Homod(minPeriod, maxPeriod);
var streamingResults = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
streaming.Update(new TValue(bars[i].Time, bars[i].Close));
streamingResults[i] = streaming.Last.Value;
}
// Batch
double[] source = new double[dataLen];
double[] batchResults = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
source[i] = bars[i].Close;
}
Homod.Batch(source, batchResults, minPeriod, maxPeriod);
// Compare all values
for (int i = 0; i < dataLen; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
#endregion
#region Span API Tests
[Fact]
public void Batch_ValidatesLengthMismatch()
{
double[] source = new double[100];
double[] output = new double[50];
var ex = Assert.Throws<ArgumentException>(() => Homod.Batch(source, output, 6, 50));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_ValidatesMinPeriod()
{
double[] source = new double[100];
double[] output = new double[100];
Assert.Throws<ArgumentOutOfRangeException>(() => Homod.Batch(source, output, 0, 50));
}
[Fact]
public void Batch_ValidatesMaxPeriod()
{
double[] source = new double[100];
double[] output = new double[100];
Assert.Throws<ArgumentOutOfRangeException>(() => Homod.Batch(source, output, 10, 10));
}
[Fact]
public void Batch_EmptyArrays_NoException()
{
double[] source = [];
double[] output = [];
var ex = Record.Exception(() => Homod.Batch(source, output, 6, 50));
Assert.Null(ex);
}
[Fact]
public void Batch_HandlesNaN()
{
double[] source = { 100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109 };
double[] output = new double[10];
Homod.Batch(source, output, 3, 8);
foreach (double v in output)
{
Assert.True(double.IsFinite(v));
}
}
#endregion
#region Chaining Tests
[Fact]
public void Chaining_PropagatesUpdates()
{
var source = new TSeries();
var homod = new Homod(source, 6, 50);
for (int i = 0; i < 200; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
}
Assert.True(homod.IsHot);
Assert.True(double.IsFinite(homod.Last.Value));
}
[Fact]
public void Chaining_MultipleIndicators()
{
var source = new TSeries();
var homod1 = new Homod(source, 6, 50);
var homod2 = new Homod(source, 8, 60);
for (int i = 0; i < 300; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
}
// Both should have values
Assert.True(double.IsFinite(homod1.Last.Value));
Assert.True(double.IsFinite(homod2.Last.Value));
// Different ranges should produce different results
Assert.NotEqual(homod1.Last.Value, homod2.Last.Value);
}
#endregion
#region Parameter Behavior Tests
[Theory]
[InlineData(3, 20)]
[InlineData(6, 50)]
[InlineData(10, 100)]
public void Update_DifferentRanges_ProducesValidResults(double minPeriod, double maxPeriod)
{
var homod = new Homod(minPeriod, maxPeriod);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
homod.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(homod.IsHot);
Assert.InRange(homod.DominantCycle, minPeriod, maxPeriod);
}
#endregion
#region Prime Tests
[Fact]
public void Prime_WarmupIndicator()
{
var homod = new Homod(6, 50);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] primeData = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
primeData[i] = bars[i].Close;
}
homod.Prime(primeData);
Assert.True(homod.IsHot);
}
#endregion
}
+362
View File
@@ -0,0 +1,362 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for HOMOD - Homodyne Discriminator.
/// Since HOMOD is a proprietary Ehlers algorithm with no standard library implementations,
/// these tests validate mathematical properties and internal consistency.
/// </summary>
public class HomodValidationTests
{
private const double Tolerance = 1e-9;
#region Mathematical Property Validation
[Fact]
public void Homod_OutputWithinConfiguredBounds()
{
// HOMOD output should always be within [minPeriod, maxPeriod] bounds
const double minPeriod = 6;
const double maxPeriod = 50;
var homod = new Homod(minPeriod, maxPeriod);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
var result = homod.Update(new TValue(bar.Time, bar.Close));
// After warmup, values should be strictly within bounds
if (homod.IsHot)
{
Assert.True(result.Value >= minPeriod && result.Value <= maxPeriod,
$"Value {result.Value} out of bounds [{minPeriod}, {maxPeriod}]");
}
}
}
[Fact]
public void Homod_SmoothTransitions()
{
// HOMOD should produce smooth transitions due to EMA smoothing
var homod = new Homod(6, 50);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double? prevValue = null;
int largeJumps = 0;
foreach (var bar in bars)
{
var result = homod.Update(new TValue(bar.Time, bar.Close));
if (prevValue.HasValue && homod.IsHot)
{
double change = Math.Abs(result.Value - prevValue.Value);
// Large jumps (>10 periods) should be rare due to smoothing
if (change > 10)
{
largeJumps++;
}
}
prevValue = result.Value;
}
// Allow at most 5% large jumps
Assert.True(largeJumps < 25, $"Too many large jumps: {largeJumps}");
}
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(456)]
public void Homod_DeterministicOutput(int seed)
{
// Same input should always produce same output
var gbm = new GBM(seed: seed);
var bars1 = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
gbm = new GBM(seed: seed);
var bars2 = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var homod1 = new Homod(6, 50);
var homod2 = new Homod(6, 50);
for (int i = 0; i < bars1.Count; i++)
{
var result1 = homod1.Update(new TValue(bars1[i].Time, bars1[i].Close));
var result2 = homod2.Update(new TValue(bars2[i].Time, bars2[i].Close));
Assert.Equal(result1.Value, result2.Value, Tolerance);
}
}
#endregion
#region Cycle Detection Validation
[Fact]
public void Homod_DetectsSyntheticCycle()
{
// Create a synthetic sine wave with known period
const int knownPeriod = 20;
var homod = new Homod(6, 50);
// Generate 500 bars of sine wave
for (int i = 0; i < 500; i++)
{
double value = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / knownPeriod);
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
}
// After convergence, detected period should be near the known period
// Allow some tolerance due to phase estimation and smoothing
Assert.InRange(homod.DominantCycle, knownPeriod - 5, knownPeriod + 5);
}
[Theory]
[InlineData(10)]
[InlineData(15)]
[InlineData(25)]
[InlineData(35)]
public void Homod_TracksVaryingCycles(int period)
{
var homod = new Homod(6, 50);
// Generate sine wave with specified period
for (int i = 0; i < 600; i++)
{
double value = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
}
// Should detect approximately the correct period
Assert.InRange(homod.DominantCycle, period - 6, period + 6);
}
#endregion
#region Mode Consistency Validation
[Fact]
public void Homod_StreamingMatchesTSeries()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming mode
var streaming = new Homod(6, 50);
var streamingResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.Update(new TValue(bars[i].Time, bars[i].Close)).Value;
}
// TSeries mode
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
var tSeriesResult = Homod.Calculate(tSeries, 6, 50);
// Compare all values
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], tSeriesResult[i].Value, Tolerance);
}
}
[Fact]
public void Homod_BatchMatchesStreaming()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming mode
var streaming = new Homod(6, 50);
var streamingResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.Update(new TValue(bars[i].Time, bars[i].Close)).Value;
}
// Batch mode
double[] source = new double[bars.Count];
double[] batchResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
source[i] = bars[i].Close;
}
Homod.Batch(source, batchResults, 6, 50);
// Compare all values
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
[Fact]
public void Homod_EventChainMatchesStreaming()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming mode
var streaming = new Homod(6, 50);
var streamingResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
streamingResults[i] = streaming.Update(new TValue(bars[i].Time, bars[i].Close)).Value;
}
// Event chain mode
var source = new TSeries();
var chained = new Homod(source, 6, 50);
var chainedResults = new List<double>();
chained.Pub += (object? _, in TValueEventArgs args) => chainedResults.Add(args.Value.Value);
foreach (var bar in bars)
{
source.Add(new TValue(bar.Time, bar.Close));
}
// Compare all values
Assert.Equal(streamingResults.Length, chainedResults.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingResults[i], chainedResults[i], Tolerance);
}
}
#endregion
#region Robustness Validation
[Fact]
public void Homod_HandlesVolatileInput()
{
var homod = new Homod(6, 50);
var random = new Random(42);
// Highly volatile random input
for (int i = 0; i < 500; i++)
{
double value = 100.0 + (random.NextDouble() - 0.5) * 50;
var result = homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
Assert.True(double.IsFinite(result.Value));
if (homod.IsHot)
{
Assert.InRange(result.Value, 6, 50);
}
}
}
[Fact]
public void Homod_HandlesConstantInput()
{
var homod = new Homod(6, 50);
// Constant input - no cycle present
for (int i = 0; i < 500; i++)
{
var result = homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
Assert.True(double.IsFinite(result.Value));
}
// Should still produce valid output within bounds
Assert.InRange(homod.DominantCycle, 6, 50);
}
[Fact]
public void Homod_HandlesTrendingInput()
{
var homod = new Homod(6, 50);
// Strong uptrend with no cyclical component
for (int i = 0; i < 500; i++)
{
double value = 100.0 + i * 0.5;
var result = homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
Assert.True(double.IsFinite(result.Value));
}
Assert.InRange(homod.DominantCycle, 6, 50);
}
[Fact]
public void Homod_HandlesNegativePrices()
{
var homod = new Homod(6, 50);
// Negative values (e.g., oscillator output)
for (int i = 0; i < 500; i++)
{
double value = Math.Sin(2.0 * Math.PI * i / 20) * 10; // Oscillates -10 to +10
var result = homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
Assert.True(double.IsFinite(result.Value));
}
Assert.InRange(homod.DominantCycle, 6, 50);
}
#endregion
#region Warmup Validation
[Fact]
public void Homod_WarmupConvergence()
{
var homod = new Homod(6, 50);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int i = 0;
foreach (var bar in bars)
{
homod.Update(new TValue(bar.Time, bar.Close));
i++;
if (i == homod.WarmupPeriod)
{
Assert.True(homod.IsHot);
break;
}
}
}
[Fact]
public void Homod_StableAfterWarmup()
{
var homod = new Homod(6, 50);
// Generate synthetic cycle
for (int i = 0; i < 200; i++)
{
double value = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 20);
homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
}
// Record values after warmup
var postWarmupValues = new List<double>();
for (int i = 200; i < 400; i++)
{
double value = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 20);
var result = homod.Update(new TValue(DateTime.UtcNow.AddSeconds(i), value));
postWarmupValues.Add(result.Value);
}
// Standard deviation should be low for stable signal
double mean = postWarmupValues.Average();
double stdDev = Math.Sqrt(postWarmupValues.Select(v => (v - mean) * (v - mean)).Average());
// Std dev should be relatively small for stable cycle detection
Assert.True(stdDev < 5, $"Standard deviation {stdDev} is too high for stable signal");
}
#endregion
}
+424
View File
@@ -0,0 +1,424 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HOMOD: Homodyne Discriminator - Ehlers dominant cycle detection using
/// homodyne multiplication and phase angle measurement.
/// </summary>
/// <remarks>
/// The Homodyne Discriminator, developed by John Ehlers, estimates the dominant
/// cycle period by multiplying the analytic signal with a delayed version of itself
/// (homodyne mixing). The resulting phase difference directly yields cycle frequency.
///
/// Algorithm:
/// 1. 4-bar weighted moving average smooths input price
/// 2. Hilbert Transform detects phase components (I and Q)
/// 3. Homodyne mixing: multiply I/Q with their 1-bar delayed values
/// 4. Re = I*I[1] + Q*Q[1], Im = I*Q[1] - Q*I[1]
/// 5. Angle = atan2(Im, Re) gives instantaneous phase change
/// 6. Period = 2π / angle with clamping and smoothing
///
/// Properties:
/// - Returns smoothed dominant cycle period
/// - Detects cycle frequency from phase rate of change
/// - Robust to noise via multiple EMA smoothing stages
/// - Exponential warmup compensation for fast convergence
///
/// Key Insight:
/// Homodyne mixing reveals instantaneous frequency by measuring the phase
/// rotation between consecutive samples. This is more responsive than
/// spectral methods while maintaining noise immunity.
/// </remarks>
[SkipLocalsInit]
public sealed class Homod : AbstractBase
{
private readonly double _minPeriod;
private readonly double _maxPeriod;
private const double TwoPi = 2.0 * Math.PI;
private const double HalfPi = Math.PI / 2.0;
[StructLayout(LayoutKind.Auto)]
private record struct State(
// Price history for 4-bar WMA
double Price0, double Price1, double Price2, double Price3,
// Smooth price history for detrender
double Sp0, double Sp1, double Sp2, double Sp3, double Sp4, double Sp5, double Sp6,
// Detrender history for Q1
double Det0, double Det1, double Det2, double Det3, double Det4, double Det5, double Det6,
// I1 history for JI
double I1_0, double I1_1, double I1_2, double I1_3, double I1_4, double I1_5, double I1_6,
// Q1 history for JQ
double Q1_0, double Q1_1, double Q1_2, double Q1_3, double Q1_4, double Q1_5, double Q1_6,
// I2, Q2 for homodyne
double I2, double I2Prev,
double Q2, double Q2Prev,
// Re, Im for angle calculation
double Re, double Im,
// Period tracking
double Period, double SmoothPeriod,
// Warmup
double WarmDecay, bool InWarmup,
// General
int BarCount, double LastValidValue
);
private State _s;
private State _ps;
/// <summary>Gets the current dominant cycle period.</summary>
public double DominantCycle => _s.SmoothPeriod;
public override bool IsHot => _s.BarCount >= WarmupPeriod;
/// <summary>
/// Creates a new Homodyne Discriminator indicator.
/// </summary>
/// <param name="minPeriod">Minimum period to detect (must be > 0).</param>
/// <param name="maxPeriod">Maximum period to detect (must be > minPeriod).</param>
public Homod(double minPeriod = 6.0, double maxPeriod = 50.0)
{
if (minPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minPeriod), "Min period must be greater than 0.");
}
if (maxPeriod <= minPeriod)
{
throw new ArgumentOutOfRangeException(nameof(maxPeriod), "Max period must be greater than min period.");
}
_minPeriod = minPeriod;
_maxPeriod = maxPeriod;
Name = $"Homod({minPeriod},{maxPeriod})";
WarmupPeriod = (int)(maxPeriod * 2);
// Initialize state with default period of 15
const double initialPeriod = 15.0;
_s = new State(
0, 0, 0, 0, // Price history
0, 0, 0, 0, 0, 0, 0, // Smooth price history
0, 0, 0, 0, 0, 0, 0, // Detrender history
0, 0, 0, 0, 0, 0, 0, // I1 history
0, 0, 0, 0, 0, 0, 0, // Q1 history
0, 0, 0, 0, // I2, Q2 with prev
0, 0, // Re, Im
initialPeriod, initialPeriod, // Period, SmoothPeriod
1.0, true, // WarmDecay, InWarmup
0, 0 // BarCount, LastValidValue
);
_ps = _s;
}
/// <summary>
/// Creates a chained Homodyne Discriminator indicator.
/// </summary>
public Homod(ITValuePublisher source, double minPeriod = 6.0, double maxPeriod = 50.0)
: this(minPeriod, maxPeriod)
{
ArgumentNullException.ThrowIfNull(source);
source.Pub += HandleInput;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleInput(object? sender, in TValueEventArgs e)
{
Update(e.Value, e.IsNew);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle non-finite values
double price = input.Value;
if (!double.IsFinite(price))
{
price = s.LastValidValue;
}
else
{
s = s with { LastValidValue = price };
}
// Increment bar count
int barCount = isNew ? s.BarCount + 1 : s.BarCount;
// Shift price history
double price3 = s.Price2;
double price2 = s.Price1;
double price1 = s.Price0;
double price0 = price;
// Calculate bandwidth based on smooth period
double bandwidth = 0.075 * s.SmoothPeriod + 0.54;
// 4-bar weighted moving average: (4*p0 + 3*p1 + 2*p2 + p3) / 10
double smoothPrice = (4.0 * price0 + 3.0 * price1 + 2.0 * price2 + price3) / 10.0;
// Shift smooth price history
double sp6 = s.Sp5;
double sp5 = s.Sp4;
double sp4 = s.Sp3;
double sp3 = s.Sp2;
double sp2 = s.Sp1;
double sp1 = s.Sp0;
double sp0 = smoothPrice;
// Hilbert Transform detrender: coefficients [0.0962, 0, 0.5769, 0, -0.5769, 0, -0.0962] * bandwidth
double detrender = (0.0962 * sp0 + 0.5769 * sp2 - 0.5769 * sp4 - 0.0962 * sp6) * bandwidth;
// Shift detrender history
double det6 = s.Det5;
double det5 = s.Det4;
double det4 = s.Det3;
double det3 = s.Det2;
double det2 = s.Det1;
double det1 = s.Det0;
double det0 = detrender;
// Q1 via Hilbert Transform of detrender
double q1 = (0.0962 * det0 + 0.5769 * det2 - 0.5769 * det4 - 0.0962 * det6) * bandwidth;
// I1 is detrender delayed by 3 bars
double i1 = det3;
// Shift I1 history for JI calculation
double i1_6 = s.I1_5;
double i1_5 = s.I1_4;
double i1_4 = s.I1_3;
double i1_3 = s.I1_2;
double i1_2 = s.I1_1;
double i1_1 = s.I1_0;
double i1_0 = i1;
// Shift Q1 history for JQ calculation
double q1_6 = s.Q1_5;
double q1_5 = s.Q1_4;
double q1_4 = s.Q1_3;
double q1_3 = s.Q1_2;
double q1_2 = s.Q1_1;
double q1_1 = s.Q1_0;
double q1_0 = q1;
// JI = Hilbert Transform of I1
double ji = (0.0962 * i1_0 + 0.5769 * i1_2 - 0.5769 * i1_4 - 0.0962 * i1_6) * bandwidth;
// JQ = Hilbert Transform of Q1
double jq = (0.0962 * q1_0 + 0.5769 * q1_2 - 0.5769 * q1_4 - 0.0962 * q1_6) * bandwidth;
// Calculate I2 and Q2 (phasor rotation)
double i2Raw = i1 - jq;
double q2Raw = q1 + ji;
// EMA smooth I2 and Q2 (alpha = 0.2)
double i2 = 0.2 * i2Raw + 0.8 * s.I2;
double q2 = 0.2 * q2Raw + 0.8 * s.Q2;
// Homodyne discriminator: multiply with previous values
double reRaw = i2 * s.I2 + q2 * s.Q2;
double imRaw = i2 * s.Q2 - q2 * s.I2;
// EMA smooth Re and Im (alpha = 0.2)
double re = 0.2 * reRaw + 0.8 * s.Re;
double im = 0.2 * imRaw + 0.8 * s.Im;
// Calculate period from angle
double period = s.Period;
double magnitude = Math.Abs(re) + Math.Abs(im);
if (magnitude > 1e-10)
{
double angle = Atan2(im, re);
if (Math.Abs(angle) > 1e-10)
{
double candidate = TwoPi / angle;
double clamped = Math.Clamp(Math.Abs(candidate), _minPeriod, _maxPeriod);
period = 0.2 * clamped + 0.8 * period;
}
}
// Smooth the period (alpha = 0.33)
const double alpha = 0.33;
double smoothPeriod = s.SmoothPeriod + alpha * (period - s.SmoothPeriod);
// Exponential warmup compensation
double result = smoothPeriod;
double warmDecay = s.WarmDecay;
bool inWarmup = s.InWarmup;
if (inWarmup)
{
warmDecay *= 1.0 - alpha;
double denom = 1.0 - warmDecay;
if (denom > 1e-10)
{
result /= denom;
}
inWarmup = warmDecay > 1e-10;
}
// Update state
_s = new State(
price0, price1, price2, price3,
sp0, sp1, sp2, sp3, sp4, sp5, sp6,
det0, det1, det2, det3, det4, det5, det6,
i1_0, i1_1, i1_2, i1_3, i1_4, i1_5, i1_6,
q1_0, q1_1, q1_2, q1_3, q1_4, q1_5, q1_6,
i2, s.I2, // I2 and I2Prev
q2, s.Q2, // Q2 and Q2Prev
re, im,
period, smoothPeriod,
warmDecay, inWarmup,
barCount, s.LastValidValue
);
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
for (int i = 0; i < len; i++)
{
var result = Update(source[i]);
vSpan[i] = result.Value;
}
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Quadrant-aware angle calculation using stable atan2.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double Atan2(double y, double x)
{
if (y == 0.0 && x == 0.0)
{
return 0.0; // Return 0 instead of error for robustness
}
double ay = Math.Abs(y);
double ax = Math.Abs(x);
double angle;
if (ax > ay)
{
angle = Math.Atan(ay / ax);
}
else
{
angle = HalfPi - Math.Atan(ax / ay);
}
if (x < 0.0)
{
angle = Math.PI - angle;
}
if (y < 0.0)
{
angle = -angle;
}
return angle;
}
public override void Reset()
{
const double initialPeriod = 15.0;
_s = new State(
0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
0, 0,
initialPeriod, initialPeriod,
1.0, true,
0, 0
);
_ps = _s;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.UtcNow, value));
}
}
/// <summary>
/// Calculates Homodyne Discriminator for a time series.
/// </summary>
public static TSeries Calculate(TSeries source, double minPeriod = 6.0, double maxPeriod = 50.0)
{
var homod = new Homod(minPeriod, maxPeriod);
return homod.Update(source);
}
/// <summary>
/// Calculates Homodyne Discriminator in-place using a pre-allocated output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output,
double minPeriod = 6.0, double maxPeriod = 50.0)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (minPeriod <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minPeriod), "Min period must be greater than 0.");
}
if (maxPeriod <= minPeriod)
{
throw new ArgumentOutOfRangeException(nameof(maxPeriod), "Max period must be greater than min period.");
}
int len = source.Length;
if (len == 0)
{
return;
}
var homod = new Homod(minPeriod, maxPeriod);
for (int i = 0; i < len; i++)
{
var result = homod.Update(new TValue(DateTime.UtcNow, source[i]));
output[i] = result.Value;
}
}
}
+258 -134
View File
@@ -1,175 +1,299 @@
# HOMOD: Homodyne Discriminator Dominant Cycle
# HOMOD: Homodyne Discriminator
## Overview and Purpose
> "The homodyne discriminator reveals instantaneous frequency by multiplying a signal with its delayed self — the phase rotation between samples directly encodes the cycle period."
The Homodyne Discriminator (HOMOD) is a cycle measurement technique introduced by John F. Ehlers in *Rocket Science for Traders* (2001) and expanded in the November 2000 *Traders Tips* column. It applies a Hilbert Transform framework to detect the instantaneous dominant cycle present in price data while minimizing lag.
The Homodyne Discriminator, developed by John Ehlers, estimates the dominant cycle period in market data using homodyne multiplication and phase angle measurement. Unlike spectral methods that analyze frequency bins, homodyne detection measures the instantaneous phase change between consecutive samples, providing responsive and noise-resistant cycle detection.
Unlike fixed-length filters, HOMOD continuously adapts to current market rhythm by converting the in-phase and quadrature components into a complex phasor pair, multiplying them homodynally, and extracting period information from the resulting phase angle. This makes it ideal for adaptive indicators and systems requiring dynamic lookback lengths.
## Historical Context
## Core Concepts
John Ehlers introduced the Homodyne Discriminator as part of his work on applying communications signal processing to financial markets. The term "homodyne" comes from radio engineering, where it describes a detection method that multiplies a signal with a locally generated reference at the same frequency.
* **Homodyne Multiplication:** Complex multiply of current and prior phasors to isolate instantaneous frequency
* **Hilbert FIR Kernel:** Ehlers 0.0962/0.5769 coefficients producing 90° phase shift with minimal distortion
* **Quadrature Rotation:** Phase-advanced components (jI, jQ) enabling orthogonal phasor construction
* **Cycle Clamping:** Limiting detected periods to realistic bounds (default 650 bars)
* **Warmup Compensation:** Exponential correction ensuring stable output from bar one
In Ehlers' adaptation, the indicator generates its own reference signals (I and Q components) using Hilbert Transform approximations, then multiplies the analytic signal with its delayed version. The resulting real and imaginary components encode the instantaneous phase difference, from which the cycle period is extracted.
## Common Settings and Parameters
The key innovation is that homodyne detection measures phase rate of change directly, rather than inferring it from spectral peaks. This makes the algorithm more responsive to cycle changes while maintaining noise immunity through multiple smoothing stages.
| Parameter | Default | Function | When to Adjust |
| ------ | ------ | ------ | ------ |
| Source | source | Data source for cycle period analysis | Switch to close for end-of-day signals or to custom synthetic blends |
| Min Period | 6 | Lower bound for detected cycle length | Increase to ignore ultrashort noise-dominated cycles |
| Max Period | 50 | Upper bound for detected cycle length | Raise for weekly/monthly studies; lower for intraday scalping |
This implementation follows Ehlers' PineScript formulation, which includes:
**Pro Tip:** Align downstream indicators (e.g., RSI, moving averages) to the live HOMOD period by rounding to the nearest integer—this maintains resonance with the markets dominant rhythm.
- 4-bar weighted moving average for input smoothing
- Hilbert Transform via FIR coefficients [0.0962, 0, 0.5769, 0, -0.5769, 0, -0.0962]
- Bandwidth adaptation based on estimated period
- Homodyne mixing with 1-bar delay
- Multiple EMA smoothing stages (α = 0.2 and α = 0.33)
- Exponential warmup compensation
## Calculation and Mathematical Foundation
## Architecture & Physics
**Explanation:**
HOMOD smooths price, applies a Hilbert Transform to obtain in-phase (I) and quadrature (Q) components, rotates them by 90°, forms phasors, multiplies each phasor by its predecessor, and derives period length from the resulting phase angle. Subsequent smoothing and clamping stabilize measurements.
### 1. Input Smoothing (4-bar WMA)
**Technical formula:**
The first stage smooths the input price using a weighted moving average:
1. **Weighted smoothing and detrending**
$$
SmoothPrice_t = \frac{4P_t + 3P_{t-1} + 2P_{t-2} + P_{t-3}}{10}
$$
$$
Detrender_t = \left(0.0962\,SP_t + 0.5769\,SP_{t-2} - 0.5769\,SP_{t-4} - 0.0962\,SP_{t-6}\right)\cdot B_t
$$
where $B_t = 0.075\cdot Period_{t-1} + 0.54$.
$$
\text{Smooth}_t = \frac{4P_t + 3P_{t-1} + 2P_{t-2} + P_{t-3}}{10}
$$
2. **Quadrature pair and phase advance**
$$
Q1_t = (0.0962\,Det_t + 0.5769\,Det_{t-2} - 0.5769\,Det_{t-4} - 0.0962\,Det_{t-6})\cdot B_t
$$
$$
I1_t = Det_{t-3}
$$
$$
jI_t = (0.0962\,I1_t + 0.5769\,I1_{t-2} - 0.5769\,I1_{t-4} - 0.0962\,I1_{t-6})\cdot B_t
$$
$$
jQ_t = (0.0962\,Q1_t + 0.5769\,Q1_{t-2} - 0.5769\,Q1_{t-4} - 0.0962\,Q1_{t-6})\cdot B_t
$$
This removes high-frequency noise while introducing minimal phase shift in the cycle detection range.
3. **Phasor construction**
$$
I2_t = 0.2\,(I1_t - jQ_t) + 0.8\,I2_{t-1},\quad Q2_t = 0.2\,(Q1_t + jI_t) + 0.8\,Q2_{t-1}
$$
### 2. Bandwidth Calculation
4. **Homodyne product and smoothing**
$$
Re_t = 0.2\,(I2_t I2_{t-1} + Q2_t Q2_{t-1}) + 0.8\,Re_{t-1}
$$
$$
Im_t = 0.2\,(I2_t Q2_{t-1} - Q2_t I2_{t-1}) + 0.8\,Im_{t-1}
$$
The Hilbert Transform coefficients are scaled by a bandwidth factor that adapts to the estimated period:
5. **Period extraction, clamp, warmup**
$$
\theta_t = \operatorname{atan2}(Im_t, Re_t)
$$
$$
Period^\*_{t} = \frac{2\pi}{\theta_t}
$$
$$
Period_t = \operatorname{clip}(|Period^\*_t|,\ Min,\ Max)
$$
$$
SmoothPeriod_t = SmoothPeriod_{t-1} + 0.33\,(Period_t - SmoothPeriod_{t-1})
$$
$$
\text{BW}_t = 0.075 \cdot \text{SmoothPeriod}_{t-1} + 0.54
$$
## Interpretation Details
This creates a feedback loop where the bandwidth narrows as shorter cycles are detected and widens for longer cycles, improving detection accuracy.
* **Cycle Tracking**
* 612 bars: fast oscillatory regimes suited to scalping and short-term countertrend trades
* 1230 bars: medium cycles aligning with swing-trading horizons
* 3060 bars: slow cycles highlighting macro rhythm or trend exhaustion zones
### 3. Hilbert Transform (Detrender)
* **Adaptive Parameterization**
* Use rounded SmoothPeriod as the lookback for RSI, stochastic, ATR channels, etc.
* Match moving-average lengths to maintain coherence between filters and underlying price rhythm.
The detrender applies the Hilbert Transform coefficients to the smoothed price:
* **Regime Analysis**
* Stable plateau in period → consistent cycle regime
* Rising period → trend elongation or consolidation broadening
* Falling period → volatility expansion, choppy markets, or nascent rotational phases
$$
\text{Det}_t = (0.0962 \cdot S_t + 0.5769 \cdot S_{t-2} - 0.5769 \cdot S_{t-4} - 0.0962 \cdot S_{t-6}) \cdot \text{BW}
$$
## Limitations and Considerations
where $S_t$ is the smoothed price. This produces the in-phase (I) component with approximately 90° phase shift.
* **Warmup Demand:** Requires ~60 bars for fully stable phasor history; early readings should be treated cautiously
* **Trend Dominance:** Persistent directional moves degrade cycle definition, causing erratic period swings
* **Noise Sensitivity:** Despite smoothing, extremely noisy instruments may oscillate near Min Period consistently
* **Clamp Bias:** Hard limits prevent detection of cycles outside bounds; adjust for instruments with known longer rhythms
* **Computational Intensity:** Multiple FIR taps and state variables raise per-bar workload versus simpler averages
### 4. Quadrature Component (Q1)
The quadrature component applies the same Hilbert Transform to the detrender:
$$
Q1_t = (0.0962 \cdot D_t + 0.5769 \cdot D_{t-2} - 0.5769 \cdot D_{t-4} - 0.0962 \cdot D_{t-6}) \cdot \text{BW}
$$
The in-phase component is simply the detrender delayed by 3 bars:
$$
I1_t = D_{t-3}
$$
### 5. Phase Rotation (JI and JQ)
Additional Hilbert Transforms compute the phase-rotated versions:
$$
JI_t = (0.0962 \cdot I1_t + 0.5769 \cdot I1_{t-2} - 0.5769 \cdot I1_{t-4} - 0.0962 \cdot I1_{t-6}) \cdot \text{BW}
$$
$$
JQ_t = (0.0962 \cdot Q1_t + 0.5769 \cdot Q1_{t-2} - 0.5769 \cdot Q1_{t-4} - 0.0962 \cdot Q1_{t-6}) \cdot \text{BW}
$$
### 6. Analytic Signal (I2 and Q2)
The final I and Q components combine the original and rotated signals:
$$
I2_{\text{raw}} = I1 - JQ
$$
$$
Q2_{\text{raw}} = Q1 + JI
$$
These are smoothed with an EMA (α = 0.2):
$$
I2_t = 0.2 \cdot I2_{\text{raw}} + 0.8 \cdot I2_{t-1}
$$
$$
Q2_t = 0.2 \cdot Q2_{\text{raw}} + 0.8 \cdot Q2_{t-1}
$$
### 7. Homodyne Multiplication
The homodyne discriminator multiplies the current analytic signal with its previous value:
$$
\text{Re}_{\text{raw}} = I2_t \cdot I2_{t-1} + Q2_t \cdot Q2_{t-1}
$$
$$
\text{Im}_{\text{raw}} = I2_t \cdot Q2_{t-1} - Q2_t \cdot I2_{t-1}
$$
Smoothed with EMA (α = 0.2):
$$
\text{Re}_t = 0.2 \cdot \text{Re}_{\text{raw}} + 0.8 \cdot \text{Re}_{t-1}
$$
$$
\text{Im}_t = 0.2 \cdot \text{Im}_{\text{raw}} + 0.8 \cdot \text{Im}_{t-1}
$$
### 8. Period Extraction
The instantaneous angular frequency is extracted from the phase angle:
$$
\theta = \text{atan2}(\text{Im}, \text{Re})
$$
$$
\text{Period}_{\text{candidate}} = \frac{2\pi}{\theta}
$$
The period is clamped and smoothed:
$$
\text{Period}_t = 0.2 \cdot \text{clamp}(|\text{candidate}|, \text{minPeriod}, \text{maxPeriod}) + 0.8 \cdot \text{Period}_{t-1}
$$
### 9. Final Smoothing
An additional EMA with α = 0.33 provides the final output:
$$
\text{SmoothPeriod}_t = \text{SmoothPeriod}_{t-1} + 0.33 \cdot (\text{Period}_t - \text{SmoothPeriod}_{t-1})
$$
### 10. Warmup Compensation
During warmup, exponential compensation accelerates convergence:
$$
\text{decay}_t = \text{decay}_{t-1} \cdot (1 - \alpha)
$$
$$
\text{Result}_t = \frac{\text{SmoothPeriod}_t}{1 - \text{decay}_t}
$$
## Mathematical Foundation
### Homodyne Detection Principle
In communications, homodyne detection multiplies a received signal $s(t)$ with a local oscillator at the same frequency $\omega_0$:
$$
s(t) \cdot \cos(\omega_0 t) = A(t) \cos(\omega_0 t + \phi(t)) \cdot \cos(\omega_0 t)
$$
Using the product-to-sum identity:
$$
= \frac{A(t)}{2}[\cos(\phi(t)) + \cos(2\omega_0 t + \phi(t))]
$$
Low-pass filtering removes the double-frequency term, leaving the phase information.
### Analytic Signal Representation
The analytic signal $z(t)$ is the original signal plus $j$ times its Hilbert transform:
$$
z(t) = x(t) + jH\{x(t)\} = A(t)e^{j\phi(t)}
$$
Multiplying consecutive samples:
$$
z(t) \cdot z^*(t-\Delta t) = A(t)A(t-\Delta t)e^{j[\phi(t) - \phi(t-\Delta t)]}
$$
The phase difference $\Delta\phi = \phi(t) - \phi(t-\Delta t)$ directly encodes the instantaneous frequency:
$$
\omega = \frac{\Delta\phi}{\Delta t}
$$
### Hilbert Transform Approximation
The FIR coefficients [0.0962, 0, 0.5769, 0, -0.5769, 0, -0.0962] approximate the ideal Hilbert transform:
$$
H(\omega) = \begin{cases}
-j & \omega > 0 \\
+j & \omega < 0
\end{cases}
$$
The zeros at odd indices ensure only 90° phase shift without amplitude distortion at the center frequency.
## Performance Profile
### Operation Count (Streaming Mode, per Bar)
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| ADD/SUB | ~25 | 1 | 25 |
| MUL | ~30 | 3 | 90 |
| DIV | 2 | 15 | 30 |
| ATAN2 | 1 | 80 | 80 |
| **Total** | **~58** | | **~225 cycles** |
| 4-bar WMA | 4 MUL, 3 ADD, 1 DIV | 20 | 20 |
| Bandwidth calc | 2 MUL, 1 ADD | 7 | 7 |
| Detrender (HT) | 4 MUL, 3 ADD | 15 | 15 |
| Q1 (HT) | 4 MUL, 3 ADD | 15 | 15 |
| JI, JQ (HT×2) | 8 MUL, 6 ADD | 30 | 30 |
| I2, Q2 (EMA×2) | 4 MUL, 2 ADD | 14 | 14 |
| Re, Im (homodyne) | 4 MUL, 2 ADD/SUB | 14 | 14 |
| Re, Im (EMA×2) | 4 MUL, 2 ADD | 14 | 14 |
| atan2 | 1 DIV, 1 ATAN, CMP | 25 | 25 |
| Period calc | 1 DIV, 2 MUL, ADD | 25 | 25 |
| Smooth period (EMA) | 2 MUL, 2 ADD | 8 | 8 |
| Warmup comp | 2 MUL, 1 DIV, CMP | 20 | 20 |
| **Total** | — | — | **~220 cycles** |
**Breakdown:**
- Weighted smooth (4-point): 3 MUL + 3 ADD + 1 DIV = 17 cycles
- Detrender FIR (4 taps): 5 MUL + 3 ADD = 18 cycles
- Q1 FIR (4 taps): 5 MUL + 3 ADD = 18 cycles
- jI/jQ FIRs (8 taps total): 10 MUL + 6 ADD = 36 cycles
- I2/Q2 IIR phasor smoothing: 4 MUL + 4 ADD = 16 cycles
- Homodyne Re/Im: 6 MUL + 4 ADD = 22 cycles
- Period extraction (atan2 + div): 1 ATAN2 + 1 DIV = 95 cycles
The homodyne discriminator is computationally efficient at O(1) per bar, dominated by the atan2 calculation and multiple Hilbert Transforms.
### Complexity Analysis
### Batch Mode (512 values, SIMD/FMA)
| Mode | Complexity | Notes |
| :--- | :---: | :--- |
| Streaming | O(1) | Fixed FIR taps (6-deep) + IIR states |
| Batch | O(n) | Linear scan, constant work per bar |
The recursive nature of EMA smoothing limits SIMD applicability. However:
**Memory**: ~128 bytes (6-bar FIR history × 4 series + IIR states)
| Operation | Scalar Ops | SIMD Potential | Notes |
| :--- | :---: | :---: | :--- |
| Hilbert coeffs | 4 MUL + 3 ADD | Partially | Indexed memory limits gains |
| EMA smoothing | Sequential | None | Data dependency chain |
| atan2 | 1 per bar | None | Scalar intrinsic |
### SIMD Analysis
| Optimization | Applicable | Notes |
| :--- | :---: | :--- |
| AVX2 vectorization | Limited | FIR taps vectorizable, IIRs sequential |
| FMA | ✅ | Hilbert kernel: `0.0962×x + 0.5769×x[2] - ...` |
| Batch parallelism | ❌ | IIR feedback prevents cross-bar parallelism |
**Optimization Notes:** The atan2 call dominates (~35% of cost). Consider:
- Fast atan2 approximation if <1° accuracy acceptable
- Precompute 2π constant, use reciprocal for division
**Expected SIMD speedup:** ~1.1x (marginal due to recursion)
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Hilbert Transform is mathematically rigorous |
| **Timeliness** | 7/10 | FIR kernel introduces ~3 bar delay |
| **Overshoot** | 8/10 | Smoothed period output is stable |
| **Smoothness** | 8/10 | IIR smoothing reduces jitter |
| **Accuracy** | 7/10 | Good for clean cycles; degrades with noise |
| **Timeliness** | 8/10 | More responsive than spectral methods |
| **Overshoot** | 8/10 | Clamping prevents extreme values |
| **Smoothness** | 8/10 | Multiple EMA stages reduce jitter |
| **Noise Rejection** | 7/10 | Adaptive bandwidth provides moderate filtering |
## Validation
HOMOD is a proprietary Ehlers indicator with limited external implementations.
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not implemented |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
| **PineScript** | ✅ | Reference implementation |
| **MQL5** | ✅ | Adaptive Lookback Homodyne variant |
Validation is performed against:
- Mathematical properties (bounded output, sine wave detection)
- PineScript formula verification
- Streaming vs batch consistency
- Mode parity (TSeries, Span, events)
## Common Pitfalls
1. **Warmup Period**: HOMOD requires approximately 2×maxPeriod bars to stabilize. The exponential warmup compensation helps but does not eliminate bias. Always check `IsHot` before using results for trading decisions.
2. **Constant Input Handling**: With constant price input, the Hilbert Transform outputs approach zero, making the atan2 calculation undefined. The implementation guards against this with magnitude checks (> 1e-10).
3. **Parameter Range**: The minPeriod/maxPeriod range must bracket the expected cycle. Unlike spectral methods, homodyne detection has no frequency bins — it produces a single period estimate. If the true cycle is far outside the range, the clamping will bias results toward the boundary.
4. **Trending Markets**: Strong trends produce low-frequency bias in the analytic signal. The period estimate will tend toward maxPeriod during sustained moves. Use additional trend filters if cycle detection during trends is required.
5. **Memory Footprint**: Each instance maintains ~40 state variables for the cascaded filters and history buffers. Per-instance memory is approximately 320 bytes.
6. **Atan2 Implementation**: The custom atan2 function matches PineScript behavior for consistency. Standard library atan2 may differ at edge cases (both arguments zero). This implementation returns 0 for robustness.
## References
* Ehlers, J. F. (2001). *Rocket Science for Traders: Digital Signal Processing Applications*. Wiley.
* Ehlers, J. F. (2000). *Traders Tips Homodyne Discriminator*. *Technical Analysis of Stocks & Commodities*.
* blackcat1402. (2023). *Ehlers Homodyne Discriminator Period Measurer* (TradingView script).
* MrTools. (2025). *Homodyne Discriminator.mq4*. Forex-Station Forums.
* Mladen. (2019). *Adaptive Lookback Indicators Homodyne Update*. MQL5 Forums.
* 3Jane. (2024). *tindicators hd.cc Implementation*. GitHub.
## Validation Sources
```mcp
Validation Sources:
Patterns: §2, §6, §7, §16, §17, §18, §19
Wolfram: "atan2(y,x)"
External: "TradingView Homodyne Discriminator","Forex-Station Homodyne Discriminator","MQL5 Adaptive Lookback Homodyne","tindicators hd.cc"
Planning: phases=function,main_loop,docs,index
- Ehlers, J.F. (2001). "Rocket Science for Traders." Wiley.
- Ehlers, J.F. (2004). "Cybernetic Analysis for Stocks and Futures." Wiley.
- Ehlers, J.F. "Homodyne Discriminator." Technical Analysis of Stocks & Commodities.
- Lyons, R.G. (2011). "Understanding Digital Signal Processing." 3rd ed. Prentice Hall.
- Oppenheim, A.V., Schafer, R.W. (2010). "Discrete-Time Signal Processing." 3rd ed. Pearson.