feat: Add Prime method to various indicators for initializing state with historical data

- Implemented Prime method in Vel, Ao, Apo, Frama, Adl, Adosc, Aobv, Cmf, Efi, Eom, Iii, Kvo, Mfi, Nvi, Obv, Pvd, Pvi, Pvo, Pvr, Pvt, Tvi, Twap, Va, Vf, Vo, Vroc, Vwad, Vwap, and Vwma classes.
- The Prime method resets the indicator state and processes the provided historical bar data to initialize the indicator.
- Added warmup period property to Adl and Wad classes to define the minimum number of data points required for validity.
- Updated benchmark tests to use Batch methods for performance evaluation.
This commit is contained in:
Miha Kralj
2026-02-11 20:38:38 -08:00
parent 75c6a9f135
commit 653aafacd8
71 changed files with 10527 additions and 242 deletions
@@ -0,0 +1,357 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
/// <summary>
/// Tests for HtDcperiodIndicator Quantower adapter.
/// Covers: constructor, properties (Source, ShowColdValues, ShortName, MinHistoryDepths),
/// OnInit, OnUpdate (HistoricalBar, NewBar, NewTick filtered), multiple bars,
/// ShowColdValues false, reinitialize, source variants.
/// </summary>
public class HtDcperiodIndicatorTests
{
// ═══════════════════════════════ Constructor ═══════════════════════════════
[Fact]
public void Constructor_InitializesName()
{
var indicator = new HtDcperiodIndicator();
Assert.Contains("HT_DCPERIOD", indicator.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_InitializesDescription()
{
var indicator = new HtDcperiodIndicator();
Assert.False(string.IsNullOrEmpty(indicator.Description));
Assert.Contains("Hilbert", indicator.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Constructor_SeparateWindowTrue()
{
var indicator = new HtDcperiodIndicator();
Assert.True(indicator.SeparateWindow);
}
[Fact]
public void Constructor_HasLineSeries()
{
var indicator = new HtDcperiodIndicator();
Assert.True(indicator.LinesSeries.Count >= 1);
}
// ═══════════════════════════════ Properties ═══════════════════════════════
[Fact]
public void Source_DefaultsToClose()
{
var indicator = new HtDcperiodIndicator();
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void ShowColdValues_DefaultsToTrue()
{
var indicator = new HtDcperiodIndicator();
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void ShortName_IsHtDcperiod()
{
var indicator = new HtDcperiodIndicator();
Assert.Equal("HT_DCPERIOD", indicator.ShortName);
}
[Fact]
public void MinHistoryDepths_Static_Is32()
{
Assert.Equal(32, HtDcperiodIndicator.MinHistoryDepths);
}
[Fact]
public void MinHistoryDepths_Interface_Is32()
{
IWatchlistIndicator indicator = new HtDcperiodIndicator();
Assert.Equal(32, indicator.MinHistoryDepths);
}
[Fact]
public void SourceCodeLink_IsNotEmpty()
{
var indicator = new HtDcperiodIndicator();
Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink));
Assert.Contains("HtDcperiod", indicator.SourceCodeLink, StringComparison.Ordinal);
}
// ═══════════════════════════════ OnInit ═══════════════════════════════════
[Fact]
public void OnInit_CreatesInternalIndicator()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
// Should not throw - internal indicator created successfully
Assert.True(true);
}
// ═══════════════════════════════ OnUpdate ═════════════════════════════════
[Fact]
public void OnUpdate_HistoricalBar_Processes()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 40; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: 100 + i,
high: 105 + i,
low: 95 + i,
close: 102 + i,
volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(40, indicator.LinesSeries[0].Count);
}
[Fact]
public void OnUpdate_NewBar_Processes()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Feed historical bars first
for (int i = 0; i < 35; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: 100 + i,
high: 105 + i,
low: 95 + i,
close: 102 + i,
volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Then process a new bar
indicator.HistoricalData.AddBar(
time: now.AddMinutes(35),
open: 135, high: 140, low: 130, close: 137, volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(36, indicator.LinesSeries[0].Count);
}
[Fact]
public void OnUpdate_NewTick_DoesNotThrow()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// NewTick should be filtered (early return) - no exception
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.True(true);
}
// ═══════════════════════════════ Multiple Bars ════════════════════════════
[Fact]
public void OnUpdate_MultipleBars_ProducesFiniteValues()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// HT_DCPERIOD needs significant warmup - feed sinusoidal data
for (int i = 0; i < 100; i++)
{
double price = 100 + 10 * Math.Sin(i * 0.3);
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: price - 1,
high: price + 2,
low: price - 2,
close: price,
volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(100, indicator.LinesSeries[0].Count);
// After warmup, values should be finite
double lastValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(lastValue));
}
[Fact]
public void OnUpdate_SingleBar_ProducesValue()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
}
// ═══════════════════════════════ ShowColdValues ═══════════════════════════
[Fact]
public void ShowColdValues_CanBeSetFalse()
{
var indicator = new HtDcperiodIndicator();
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
}
[Fact]
public void ShowColdValues_False_ProcessesWithoutError()
{
var indicator = new HtDcperiodIndicator();
indicator.ShowColdValues = false;
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 40; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: 100 + i, high: 105 + i, low: 95 + i,
close: 102 + i, volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.True(true);
}
// ═══════════════════════════════ Reinitialize ═════════════════════════════
[Fact]
public void Reinitialize_ResetsState()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: 100 + i, high: 105 + i, low: 95 + i, close: 102 + i, volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Reinitialize
indicator.Initialize();
// Should process fresh data without error
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(100 + i),
open: 200 + i, high: 205 + i, low: 195 + i, close: 202 + i, volume: 2000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.True(true);
}
// ═══════════════════════════════ Source Variants ══════════════════════════
[Fact]
public void Source_SetToOpen_Accepted()
{
var indicator = new HtDcperiodIndicator();
indicator.Source = SourceType.Open;
Assert.Equal(SourceType.Open, indicator.Source);
}
[Fact]
public void Source_SetToHigh_Accepted()
{
var indicator = new HtDcperiodIndicator();
indicator.Source = SourceType.High;
Assert.Equal(SourceType.High, indicator.Source);
}
[Fact]
public void Source_SetToLow_Accepted()
{
var indicator = new HtDcperiodIndicator();
indicator.Source = SourceType.Low;
Assert.Equal(SourceType.Low, indicator.Source);
}
[Fact]
public void Source_DifferentSources_ProcessWithoutError()
{
foreach (var source in new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close })
{
var indicator = new HtDcperiodIndicator();
indicator.Source = source;
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 40; i++)
{
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: 100 + i, high: 105 + i, low: 95 + i,
close: 102 + i, volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
}
Assert.True(true);
}
// ═══════════════════════════════ OnBackGround ═════════════════════════════
[Fact]
public void OnBackGround_IsTrue()
{
var indicator = new HtDcperiodIndicator();
Assert.True(indicator.OnBackGround);
}
// ═══════════════════════════════ Value Assertions ═════════════════════════
[Fact]
public void Values_AfterWarmup_ArePositive()
{
var indicator = new HtDcperiodIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Feed sinusoidal data with known period (~21 bars)
for (int i = 0; i < 100; i++)
{
double price = 100 + 10 * Math.Sin(2 * Math.PI * i / 21.0);
indicator.HistoricalData.AddBar(
time: now.AddMinutes(i),
open: price - 0.5,
high: price + 1,
low: price - 1,
close: price,
volume: 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Dominant cycle period should be positive after warmup
double lastValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastValue > 0, $"Expected positive period, got {lastValue}");
}
}
+432
View File
@@ -6,6 +6,8 @@ namespace QuanTAlib.Tests.Cycles;
public class HtDcperiodTests
{
// ── Constructor ──────────────────────────────────────────────────────
[Fact]
public void Constructor_SetsDefaults()
{
@@ -15,6 +17,38 @@ public class HtDcperiodTests
Assert.False(ht.IsHot);
}
[Fact]
public void Constructor_WithPublisher_SubscribesToEvents()
{
var source = new TSeries();
var ht = new HtDcperiod(source);
Assert.False(ht.IsHot);
// Feed data through publisher
for (int i = 0; i < 40; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + Math.Sin(i * 0.3) * 10));
}
Assert.True(ht.IsHot);
Assert.True(double.IsFinite(ht.Last.Value));
}
[Fact]
public void Constructor_WithNullPublisher_Throws()
{
Assert.Throws<ArgumentNullException>(() => new HtDcperiod(null!));
}
[Fact]
public void Last_DefaultBeforeAnyUpdate()
{
var ht = new HtDcperiod();
Assert.Equal(default, ht.Last);
}
// ── IsHot & Warmup ──────────────────────────────────────────────────
[Fact]
public void Update_BecomesHotAfterWarmup()
{
@@ -31,6 +65,171 @@ public class HtDcperiodTests
Assert.True(double.IsFinite(ht.Last.Value));
}
[Fact]
public void IsHot_FalseBeforeWarmup()
{
var ht = new HtDcperiod();
for (int i = 0; i < 30; i++)
{
ht.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
Assert.False(ht.IsHot);
}
[Fact]
public void WarmupPeriod_Returns32()
{
var ht = new HtDcperiod();
Assert.Equal(32, ht.WarmupPeriod);
}
// ── Update (streaming) ──────────────────────────────────────────────
[Fact]
public void Update_FirstBarsReturnZero()
{
var ht = new HtDcperiod();
// During WMA initialization (first ~37 bars), output should be 0
var result = ht.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(0.0, result.Value);
}
[Fact]
public void Update_ProducesFiniteValuesAfterWarmup()
{
var ht = new HtDcperiod();
var gbm = new GBM(seed: 99);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TValue lastResult = default;
foreach (var bar in bars)
{
lastResult = ht.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(lastResult.Value));
}
[Fact]
public void Update_PeriodInValidRange()
{
// The dominant cycle period should be clamped between 6 and 50
var ht = new HtDcperiod();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
bool anyHot = false;
foreach (var bar in bars)
{
var result = ht.Update(new TValue(bar.Time, bar.Close));
if (ht.IsHot)
{
anyHot = true;
// Period output should be in a reasonable range
Assert.True(double.IsFinite(result.Value),
$"Period should be finite, got {result.Value}");
}
}
Assert.True(anyHot);
}
// ── Bar Correction (isNew=false) ────────────────────────────────────
[Fact]
public void SameBarUpdate_ReturnsSameValue()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
// Prime with data
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.1) * 10));
}
Assert.True(ht.IsHot);
// First update (new bar)
var result1 = ht.Update(new TValue(now.AddMinutes(50), 105), isNew: true);
// Same bar update with different price
var result2 = ht.Update(new TValue(now.AddMinutes(50), 106), isNew: false);
// isNew=false should rollback and reapply - result should equal result1 since
// bar correction restores previous state first
Assert.Equal(result1.Value, result2.Value);
}
[Fact]
public void BarCorrection_DoesNotAdvanceState()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + i));
}
// New bar
ht.Update(new TValue(now.AddMinutes(50), 150), isNew: true);
var afterNew = ht.Last;
// Multiple corrections should not change the state relative to the new bar
ht.Update(new TValue(now.AddMinutes(50), 151), isNew: false);
ht.Update(new TValue(now.AddMinutes(50), 152), isNew: false);
ht.Update(new TValue(now.AddMinutes(50), 150), isNew: false);
var afterCorrections = ht.Last;
Assert.Equal(afterNew.Value, afterCorrections.Value);
}
// ── NaN handling ────────────────────────────────────────────────────
[Fact]
public void Update_NaN_BeforeAnyValidData_ReturnsZero()
{
var ht = new HtDcperiod();
var result = ht.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.Equal(0.0, result.Value);
}
[Fact]
public void Update_NaN_UsesLastValidPrice()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
// Feed valid data to warm up
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5));
}
Assert.True(ht.IsHot);
// Feed NaN - should use last valid price
var result = ht.Update(new TValue(now.AddMinutes(50), double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Infinity_UsesLastValidPrice()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + i * 0.5));
}
var result = ht.Update(new TValue(now.AddMinutes(50), double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
}
// ── Reset ───────────────────────────────────────────────────────────
[Fact]
public void Reset_ClearsState()
{
@@ -46,4 +245,237 @@ public class HtDcperiodTests
Assert.False(ht.IsHot);
Assert.Equal(default, ht.Last);
}
[Fact]
public void Reset_AllowsReuse()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
// First use
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5));
}
Assert.True(ht.IsHot);
var firstResult = ht.Last.Value;
// Reset and reuse
ht.Reset();
Assert.False(ht.IsHot);
for (int i = 0; i < 50; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + Math.Sin(i * 0.2) * 5));
}
Assert.True(ht.IsHot);
Assert.Equal(firstResult, ht.Last.Value);
}
// ── Batch/TSeries Update ────────────────────────────────────────────
[Fact]
public void Update_TSeries_ReturnsCorrectCount()
{
var ht = new HtDcperiod();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var result = ht.Update(bars.Close);
Assert.Equal(100, result.Count);
}
[Fact]
public void Update_EmptyTSeries_ReturnsEmpty()
{
var ht = new HtDcperiod();
var result = ht.Update(new TSeries());
Assert.Empty(result);
}
[Fact]
public void Batch_TSeries_MatchesStreaming()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// Batch
var batchResult = HtDcperiod.Batch(series);
// Streaming
var streaming = new HtDcperiod();
var streamingResults = new TSeries();
foreach (var item in series)
{
streamingResults.Add(streaming.Update(item));
}
// Compare
Assert.Equal(batchResult.Count, streamingResults.Count);
for (int i = 0; i < batchResult.Count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i].Value, 1e-10);
}
}
[Fact]
public void Batch_Span_MatchesStreaming()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var values = bars.Close.Values.ToArray();
// Span batch
var spanOutput = new double[values.Length];
HtDcperiod.Batch(values, spanOutput);
// Streaming
var streaming = new HtDcperiod();
var streamingResults = new double[values.Length];
for (int i = 0; i < values.Length; i++)
{
streamingResults[i] = streaming.Update(new TValue(DateTime.UtcNow.AddTicks(i), values[i])).Value;
}
// Compare
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(streamingResults[i], spanOutput[i], 1e-10);
}
}
[Fact]
public void Batch_Span_OutputTooShort_Throws()
{
var source = new double[10];
var output = new double[5];
Assert.Throws<ArgumentException>(() => HtDcperiod.Batch(source, output));
}
// ── Calculate ───────────────────────────────────────────────────────
[Fact]
public void Calculate_ReturnsBothResultsAndIndicator()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (results, indicator) = HtDcperiod.Calculate(bars.Close);
Assert.Equal(100, results.Count);
Assert.True(indicator.IsHot);
}
// ── Prime ───────────────────────────────────────────────────────────
[Fact]
public void Prime_WamsUpIndicator()
{
var ht = new HtDcperiod();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(80, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var values = bars.Close.Values.ToArray();
ht.Prime(values);
Assert.True(ht.IsHot);
}
[Fact]
public void Prime_WithStepParameter()
{
var ht = new HtDcperiod();
var values = new double[50];
for (int i = 0; i < 50; i++)
{
values[i] = 100 + Math.Sin(i * 0.2) * 5;
}
ht.Prime(values, TimeSpan.FromMinutes(5));
Assert.True(ht.IsHot);
}
// ── Determinism ─────────────────────────────────────────────────────
[Fact]
public void TwoInstances_SameInput_SameOutput()
{
var ht1 = new HtDcperiod();
var ht2 = new HtDcperiod();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
var tv = new TValue(bar.Time, bar.Close);
var r1 = ht1.Update(tv);
var r2 = ht2.Update(tv);
Assert.Equal(r1.Value, r2.Value);
}
}
// ── Constant price ──────────────────────────────────────────────────
[Fact]
public void ConstantPrice_ProducesFiniteOutput()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
for (int i = 0; i < 80; i++)
{
var result = ht.Update(new TValue(now.AddMinutes(i), 100.0));
Assert.True(double.IsFinite(result.Value),
$"Bar {i}: Expected finite, got {result.Value}");
}
}
// ── Sinusoidal input ────────────────────────────────────────────────
[Fact]
public void SinusoidalInput_DetectsApproximatePeriod()
{
var ht = new HtDcperiod();
var now = DateTime.UtcNow;
// Feed a clean sinusoidal with period ~20 bars
int inputPeriod = 20;
double omega = 2.0 * Math.PI / inputPeriod;
for (int i = 0; i < 300; i++)
{
ht.Update(new TValue(now.AddMinutes(i), 100 + 10 * Math.Sin(omega * i)));
}
// After sufficient data, the detected period should be
// somewhere in the ballpark of the input period
Assert.True(ht.IsHot);
double detected = ht.Last.Value;
Assert.True(double.IsFinite(detected));
// The Hilbert transform period detection is approximate
Assert.True(detected >= 6.0 && detected <= 50.0,
$"Detected period {detected} should be in [6, 50] range");
}
// ── Dispose ─────────────────────────────────────────────────────────
[Fact]
public void Dispose_DoesNotThrow()
{
var ht = new HtDcperiod();
ht.Update(new TValue(DateTime.UtcNow, 100));
ht.Dispose();
Assert.True(true); // S2699: explicit assertion for dispose-only test
}
[Fact]
public void Dispose_WithPublisher_DoesNotThrow()
{
// HtDcperiod subscribes to source but does not track the source
// reference for unsubscription — Dispose still must not throw
var source = new TSeries();
var ht = new HtDcperiod(source);
source.Add(new TValue(DateTime.UtcNow, 100));
Assert.True(double.IsFinite(ht.Last.Value) || ht.Last.Value == 0.0);
ht.Dispose();
}
}