adding missing validations

This commit is contained in:
Miha Kralj
2026-02-26 09:59:44 -08:00
parent 467a8c1cef
commit 9ab37c1200
231 changed files with 60015 additions and 302 deletions
@@ -0,0 +1,129 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class TdSeqIndicatorTests
{
[Fact]
public void TdSeqIndicator_Constructor_SetsDefaults()
{
var indicator = new TdSeqIndicator();
Assert.Equal(4, indicator.ComparePeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("TD_SEQ - TD Sequential", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void TdSeqIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
Assert.Equal(0, TdSeqIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void TdSeqIndicator_ShortName_IncludesComparePeriod()
{
var indicator = new TdSeqIndicator { ComparePeriod = 6 };
indicator.Initialize();
Assert.Contains("TD_SEQ", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("6", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void TdSeqIndicator_SourceCodeLink_IsValid()
{
var indicator = new TdSeqIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Td_seq.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void TdSeqIndicator_Initialize_CreatesTwoLineSeries()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
indicator.Initialize();
// Setup line + Countdown line
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void TdSeqIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double setupValue = indicator.LinesSeries[0].GetValue(0);
double countdownValue = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(setupValue));
Assert.True(double.IsFinite(countdownValue));
}
[Fact]
public void TdSeqIndicator_ProcessUpdate_NewBar_UpdatesValue()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 130, 110, 125);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.True(indicator.LinesSeries[0].Count >= 2);
}
[Fact]
public void TdSeqIndicator_Parameters_CanBeChanged()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
indicator.ComparePeriod = 6;
Assert.Equal(6, indicator.ComparePeriod);
Assert.Equal(0, TdSeqIndicator.MinHistoryDepths);
}
[Fact]
public void TdSeqIndicator_RisingPrices_SetupCountPositive()
{
var indicator = new TdSeqIndicator { ComparePeriod = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double p = 100.0 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), p, p + 2, p - 2, p);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// After 9+ qualifying bars, setup line should show a positive value
double setupValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(setupValue >= 0, $"Expected non-negative setup for rising prices, got {setupValue}");
}
}
@@ -0,0 +1,55 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class TdSeqIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Compare Period", sortIndex: 1, 1, 100, 1, 0)]
public int ComparePeriod { get; set; } = 4;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private TdSeq _tdSeq = null!;
private readonly LineSeries _setupLine;
private readonly LineSeries _countdownLine;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"TD_SEQ ({ComparePeriod})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/td_seq/Td_seq.Quantower.cs";
public TdSeqIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "TD_SEQ - TD Sequential";
Description = "Tom DeMark's exhaustion counting system: Setup (±1 to ±9) and Countdown (±1 to ±13) phases detecting trend reversals.";
_setupLine = new LineSeries("Setup", Color.Yellow, 2, LineStyle.Solid);
_countdownLine = new LineSeries("Countdown", Color.Cyan, 1, LineStyle.Solid);
AddLineSeries(_setupLine);
AddLineSeries(_countdownLine);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_tdSeq = new TdSeq(ComparePeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _tdSeq.Update(this.GetInputBar(args), args.IsNewBar());
_setupLine.SetValue(_tdSeq.Setup, _tdSeq.IsHot, ShowColdValues);
_countdownLine.SetValue(_tdSeq.Countdown, _tdSeq.IsHot, ShowColdValues);
}
}
+467
View File
@@ -0,0 +1,467 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class TdSeqTests
{
private static TBar Bar(double close, double high = 0, double low = 0) =>
new(DateTime.UtcNow, open: close, high: high == 0 ? close + 1 : high,
low: low == 0 ? close - 1 : low, close: close, volume: 1000);
private static TBar[] MakeBars(double[] closes)
{
var bars = new TBar[closes.Length];
for (int i = 0; i < closes.Length; i++)
{
bars[i] = Bar(closes[i]);
}
return bars;
}
private static TBar[] GbmBars(int count, int seed = 42)
{
var gbm = new GBM(100.0, 0.02, 0.1, seed: seed);
var series = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var bars = new TBar[count];
for (int i = 0; i < count; i++)
{
double c = series.Close.Values[i];
double h = series.High.Values[i];
double l = series.Low.Values[i];
bars[i] = new TBar(DateTime.UtcNow.AddMinutes(i), c, h, l, c, 1000);
}
return bars;
}
// ───── A) Constructor validation ─────
[Fact]
public void Constructor_ZeroComparePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new TdSeq(comparePeriod: 0));
Assert.Equal("comparePeriod", ex.ParamName);
}
[Fact]
public void Constructor_NegativeComparePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new TdSeq(comparePeriod: -1));
Assert.Equal("comparePeriod", ex.ParamName);
}
[Fact]
public void Constructor_Default_SetsProperties()
{
var td = new TdSeq();
Assert.Equal("TdSeq(4)", td.Name);
Assert.Equal(5, td.WarmupPeriod);
Assert.False(td.IsHot);
}
[Fact]
public void Constructor_CustomPeriod_SetsProperties()
{
var td = new TdSeq(comparePeriod: 3);
Assert.Equal("TdSeq(3)", td.Name);
Assert.Equal(4, td.WarmupPeriod);
}
// ───── B) Basic calculation ─────
[Fact]
public void Update_ReturnsTValue()
{
var td = new TdSeq();
var result = td.Update(Bar(100.0));
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_Last_IsAccessible()
{
var td = new TdSeq();
td.Update(Bar(100.0));
Assert.False(td.IsHot);
Assert.Equal("TdSeq(4)", td.Name);
}
[Fact]
public void Update_SellSetup_CountsPositive()
{
var td = new TdSeq(comparePeriod: 4);
// Feed 5 bars to get IsHot, then continue rising
// Rising closes: close > close[4] for consecutive bars → sell setup
double[] prices = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
foreach (double p in prices)
{
td.Update(Bar(p));
}
Assert.True(td.IsHot);
Assert.True(td.Setup > 0, $"Expected positive setup, got {td.Setup}");
}
[Fact]
public void Update_BuySetup_CountsNegative()
{
var td = new TdSeq(comparePeriod: 4);
// Falling closes: close < close[4] → buy setup (negative)
double[] prices = [110, 109, 108, 107, 106, 105, 104, 103, 102, 101, 100];
foreach (double p in prices)
{
td.Update(Bar(p));
}
Assert.True(td.IsHot);
Assert.True(td.Setup < 0, $"Expected negative setup, got {td.Setup}");
}
[Fact]
public void Update_SetupComplete_ReachesNine()
{
var td = new TdSeq(comparePeriod: 4);
// Steadily rising for 13+ bars (9 qualify for sell setup after 4-bar lookback)
// Bars 0-3: prime the history. Bars 4-12: each > close[4] → consecutive sell setup
double[] prices = new double[20];
for (int i = 0; i < 20; i++) { prices[i] = 100.0 + i; }
foreach (double p in prices)
{
td.Update(Bar(p));
}
// After 9 consecutive qualifying bars setup should have been clamped to 9
Assert.Equal(9, td.Setup);
}
// ───── C) State + bar correction ─────
[Fact]
public void Update_IsNew_True_AdvancesState()
{
var td = new TdSeq();
td.Update(Bar(100.0), isNew: true);
_ = td.Setup; // capture state after first update
td.Update(Bar(200.0), isNew: true);
// Second bar may have different setup due to price change
Assert.False(td.IsHot); // still warming up
}
[Fact]
public void Update_IsNew_False_IsIdempotent()
{
var td = new TdSeq(comparePeriod: 4);
double[] prices = [100, 101, 102, 103, 104, 105, 106];
foreach (double p in prices)
{
td.Update(Bar(p), isNew: true);
}
// Correct last bar twice — same result
td.Update(Bar(106.5), isNew: false);
double v1 = td.Last.Value;
td.Update(Bar(106.5), isNew: false);
double v2 = td.Last.Value;
Assert.Equal(v1, v2);
}
[Fact]
public void Update_IterativeCorrections_Restore()
{
var td = new TdSeq(comparePeriod: 4);
double[] prices = [100, 101, 102, 103, 104, 105, 106];
foreach (double p in prices)
{
td.Update(Bar(p), isNew: true);
}
double baseline = td.Last.Value;
// Correct to various prices then back to original
td.Update(Bar(999.0), isNew: false);
td.Update(Bar(50.0), isNew: false);
td.Update(Bar(106.0), isNew: false);
Assert.Equal(baseline, td.Last.Value);
}
[Fact]
public void Reset_ClearsAllState()
{
var td = new TdSeq();
double[] bars = new double[30];
for (int i = 0; i < 30; i++) { bars[i] = 100.0 + i; }
foreach (double p in bars)
{
td.Update(Bar(p));
}
Assert.True(td.IsHot);
td.Reset();
Assert.False(td.IsHot);
Assert.Equal(0, td.Setup);
Assert.Equal(0, td.Countdown);
Assert.Equal(default, td.Last);
}
[Fact]
public void Reset_ThenReFeed_GivesSameResult()
{
var td = new TdSeq(comparePeriod: 4);
var bars = MakeBars([100, 101, 102, 103, 104, 105, 106, 107]);
foreach (var b in bars) { td.Update(b); }
double first = td.Last.Value;
td.Reset();
foreach (var b in bars) { td.Update(b); }
double second = td.Last.Value;
Assert.Equal(first, second);
}
// ───── D) Warmup / convergence ─────
[Fact]
public void IsHot_FalseBeforeEnoughBars()
{
var td = new TdSeq(comparePeriod: 4);
for (int i = 0; i < 4; i++)
{
td.Update(Bar(100.0 + i));
Assert.False(td.IsHot);
}
}
[Fact]
public void IsHot_TrueAfterWarmupPeriod()
{
var td = new TdSeq(comparePeriod: 4);
for (int i = 0; i < 5; i++)
{
td.Update(Bar(100.0 + i));
}
Assert.True(td.IsHot);
}
[Fact]
public void WarmupPeriod_IsComparePeriodPlusOne()
{
Assert.Equal(5, new TdSeq(4).WarmupPeriod);
Assert.Equal(4, new TdSeq(3).WarmupPeriod);
Assert.Equal(2, new TdSeq(1).WarmupPeriod);
}
// ───── E) Robustness ─────
[Fact]
public void Update_NaN_Close_UsesLastValid()
{
var td = new TdSeq(comparePeriod: 4);
var bars = GbmBars(10);
foreach (var b in bars) { td.Update(b); }
td.Update(new TBar(DateTime.UtcNow, 100, 110, 90, double.NaN, 1000));
Assert.True(double.IsFinite(td.Last.Value));
}
[Fact]
public void Update_Infinity_Close_UsesLastValid()
{
var td = new TdSeq(comparePeriod: 4);
var bars = GbmBars(10);
foreach (var b in bars) { td.Update(b); }
td.Update(new TBar(DateTime.UtcNow, 100, 110, 90, double.PositiveInfinity, 1000));
Assert.True(double.IsFinite(td.Last.Value));
}
[Fact]
public void Update_BatchNaN_Safe()
{
var td = new TdSeq(comparePeriod: 4);
for (int i = 0; i < 5; i++)
{
td.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
}
Assert.True(double.IsFinite(td.Last.Value));
}
// ───── F) Consistency (streaming == eventing) ─────
[Fact]
public void AllModes_ProduceSameResults()
{
int count = 200;
var gbm = new GBM(100.0, 0.02, 0.1, seed: 77);
var tbarSeries = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var bars = new TBar[count];
for (int i = 0; i < count; i++)
{
bars[i] = new TBar(
DateTime.UtcNow.AddMinutes(i),
tbarSeries.Close.Values[i],
tbarSeries.High.Values[i],
tbarSeries.Low.Values[i],
tbarSeries.Close.Values[i],
1000);
}
// 1. Streaming
var streaming = new TdSeq(4);
var streamResults = new double[count];
for (int i = 0; i < count; i++)
{
streamResults[i] = streaming.Update(bars[i]).Value;
}
// 2. Event-based via TBarSeries
var barSource = new TBarSeries();
var eventIndicator = new TdSeq(barSource, 4);
var eventResults = new double[count];
for (int i = 0; i < count; i++)
{
barSource.Add(bars[i]);
eventResults[i] = eventIndicator.Last.Value;
}
// Compare all
for (int i = 0; i < count; i++)
{
Assert.Equal(streamResults[i], eventResults[i]);
}
}
// ───── G) Countdown phase ─────
[Fact]
public void Countdown_StartsAfterSetupCompletes()
{
var td = new TdSeq(comparePeriod: 4);
// Need 9 consecutive qualifying sell-setup bars after warmup
// Warmup = 4 bars, then 9 more bars where close > close[4]
double[] prices = new double[30];
for (int i = 0; i < 30; i++) { prices[i] = 100.0 + i; }
foreach (double p in prices)
{
td.Update(Bar(p, high: p + 2, low: p - 2));
}
// After 9+ qualifying bars, setup should complete and countdown may be active
// Setup is clamped at 9, countdown starts at 0 and increments when conditions met
Assert.Equal(9, td.Setup); // setup stays at 9 (clamped)
}
[Fact]
public void SetupCount_ResetWhenDirectionFlips()
{
var td = new TdSeq(comparePeriod: 4);
// First go up (sell setup)
double[] rising = [100, 101, 102, 103, 104, 105, 106, 107];
foreach (double p in rising) { td.Update(Bar(p)); }
Assert.True(td.Setup > 0);
// Then go sharply down (buy setup)
double[] falling = [80, 79, 78, 77, 76, 75, 74, 73];
foreach (double p in falling) { td.Update(Bar(p)); }
Assert.True(td.Setup < 0, $"Expected negative setup after reversal, got {td.Setup}");
}
// ───── H) Chainability ─────
[Fact]
public void PubEvent_FiresOnUpdate()
{
var td = new TdSeq();
int firedCount = 0;
td.Pub += (object? _, in TValueEventArgs _) => firedCount++;
td.Update(Bar(100.0));
Assert.Equal(1, firedCount);
}
[Fact]
public void EventChaining_Works()
{
var source = new TBarSeries();
var td = new TdSeq(source, comparePeriod: 4);
var downstream = new TSeries();
td.Pub += (object? _, in TValueEventArgs e) => downstream.Add(e.Value);
for (int i = 0; i < 10; i++)
{
source.Add(Bar(100.0 + i));
}
Assert.Equal(10, downstream.Count);
}
// ───── Calculate ─────
[Fact]
public void Calculate_ReturnsFullSeries()
{
var gbm = new GBM(100.0, 0.02, 0.1, seed: 42);
var tbarSeries = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int count = 50;
var barSeries = new TBarSeries();
for (int i = 0; i < count; i++)
{
barSeries.Add(new TBar(
DateTime.UtcNow.AddMinutes(i),
tbarSeries.Close.Values[i],
tbarSeries.High.Values[i],
tbarSeries.Low.Values[i],
tbarSeries.Close.Values[i],
1000));
}
TSeries results = TdSeq.Calculate(barSeries, comparePeriod: 4);
Assert.Equal(count, results.Count);
}
[Fact]
public void Calculate_MatchesStreaming()
{
int count = 100;
var gbm = new GBM(100.0, 0.02, 0.1, seed: 7);
var tbarSeries = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var bars = new TBar[count];
var barSeries = new TBarSeries();
for (int i = 0; i < count; i++)
{
bars[i] = new TBar(
DateTime.UtcNow.AddMinutes(i),
tbarSeries.Close.Values[i],
tbarSeries.High.Values[i],
tbarSeries.Low.Values[i],
tbarSeries.Close.Values[i],
1000);
barSeries.Add(bars[i]);
}
// Streaming
var streaming = new TdSeq(4);
var streamResults = new double[count];
for (int i = 0; i < count; i++)
{
streamResults[i] = streaming.Update(bars[i]).Value;
}
// Batch
TSeries batchResults = TdSeq.Calculate(barSeries, comparePeriod: 4);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i]);
}
}
}
@@ -0,0 +1,236 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// TD_SEQ Validation Tests — self-consistency only (no external library equivalent).
/// Validates: streaming == batch, determinism, NaN safety, direction reversal logic.
/// </summary>
public sealed class TdSeqValidationTests
{
private static TBar[] MakeBars(int count, int seed = 42)
{
var gbm = new GBM(100.0, 0.02, 0.1, seed: seed);
var tbarSeries = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var bars = new TBar[count];
for (int i = 0; i < count; i++)
{
bars[i] = new TBar(
DateTime.UtcNow.AddMinutes(i),
tbarSeries.Close.Values[i],
tbarSeries.High.Values[i],
tbarSeries.Low.Values[i],
tbarSeries.Close.Values[i],
1000);
}
return bars;
}
// ─── Self-consistency: streaming == batch ───
[Fact]
public void Streaming_EqualsBatch_Period4()
{
var bars = MakeBars(500);
var barSeries = new TBarSeries();
foreach (var b in bars) { barSeries.Add(b); }
// Streaming
var streaming = new TdSeq(4);
var streamResults = new double[bars.Length];
for (int i = 0; i < bars.Length; i++)
{
streamResults[i] = streaming.Update(bars[i]).Value;
}
// Batch via Calculate
TSeries batchResults = TdSeq.Calculate(barSeries, 4);
for (int i = 0; i < bars.Length; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i]);
}
}
[Fact]
public void Streaming_EqualsBatch_Period2()
{
var bars = MakeBars(200, seed: 13);
var barSeries = new TBarSeries();
foreach (var b in bars) { barSeries.Add(b); }
var streaming = new TdSeq(2);
var streamResults = new double[bars.Length];
for (int i = 0; i < bars.Length; i++)
{
streamResults[i] = streaming.Update(bars[i]).Value;
}
TSeries batchResults = TdSeq.Calculate(barSeries, 2);
for (int i = 0; i < bars.Length; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i]);
}
}
// ─── Determinism: same input → same output ───
[Fact]
public void Determinism_SameSeed_SameResults()
{
var bars1 = MakeBars(100, seed: 99);
var bars2 = MakeBars(100, seed: 99);
var td1 = new TdSeq(4);
var td2 = new TdSeq(4);
for (int i = 0; i < bars1.Length; i++)
{
double v1 = td1.Update(bars1[i]).Value;
double v2 = td2.Update(bars2[i]).Value;
Assert.Equal(v1, v2);
}
}
// ─── Known-value spot check ───
[Fact]
public void SellSetup_PureRising_CountsCorrectly()
{
// Pure monotone rising: bars 0-3 prime, bars 4-12 each qualify as sell setup
// After 9 qualifying bars the setup count clamps to 9
var td = new TdSeq(4);
int maxSetup = 0;
for (int i = 0; i < 20; i++)
{
double p = 100.0 + i;
td.Update(new TBar(DateTime.UtcNow.AddMinutes(i), p, p + 2, p - 2, p, 1000));
if (td.Setup > maxSetup) { maxSetup = td.Setup; }
}
Assert.Equal(9, maxSetup);
}
[Fact]
public void BuySetup_PureFalling_CountsNegativeNine()
{
var td = new TdSeq(4);
int minSetup = 0;
for (int i = 0; i < 20; i++)
{
double p = 200.0 - i;
td.Update(new TBar(DateTime.UtcNow.AddMinutes(i), p, p + 2, p - 2, p, 1000));
if (td.Setup < minSetup) { minSetup = td.Setup; }
}
Assert.Equal(-9, minSetup);
}
// ─── Setup clamp: never exceeds ±9 ───
[Fact]
public void Setup_NeverExceedsNine()
{
var bars = MakeBars(500, seed: 7);
var td = new TdSeq(4);
foreach (var b in bars)
{
td.Update(b);
Assert.True(td.Setup >= -9 && td.Setup <= 9,
$"Setup {td.Setup} out of range");
}
}
// ─── Countdown clamp: never exceeds ±13 ───
[Fact]
public void Countdown_NeverExceedsThirteen()
{
var bars = MakeBars(500, seed: 7);
var td = new TdSeq(4);
foreach (var b in bars)
{
td.Update(b);
Assert.True(td.Countdown >= -13 && td.Countdown <= 13,
$"Countdown {td.Countdown} out of range");
}
}
// ─── Pre-warmup output is zero ───
[Fact]
public void PreWarmup_OutputIsZero()
{
var td = new TdSeq(4);
for (int i = 0; i < 4; i++)
{
double v = td.Update(new TBar(DateTime.UtcNow, 100 + i, 102 + i, 98 + i, 100 + i, 1000)).Value;
Assert.Equal(0.0, v);
}
}
// ─── NaN inputs: output remains finite ───
[Fact]
public void NaN_OutputRemainsFinite()
{
var td = new TdSeq(4);
var bars = MakeBars(20);
foreach (var b in bars) { td.Update(b); }
// Insert NaN bar
td.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
Assert.True(double.IsFinite(td.Last.Value));
}
// ─── Event-based matches streaming ───
[Fact]
public void EventBased_MatchesStreaming()
{
var bars = MakeBars(300, seed: 55);
var streaming = new TdSeq(4);
var streamResults = new double[bars.Length];
for (int i = 0; i < bars.Length; i++)
{
streamResults[i] = streaming.Update(bars[i]).Value;
}
var barSource = new TBarSeries();
var eventTd = new TdSeq(barSource, 4);
var eventResults = new double[bars.Length];
for (int i = 0; i < bars.Length; i++)
{
barSource.Add(bars[i]);
eventResults[i] = eventTd.Last.Value;
}
for (int i = 0; i < bars.Length; i++)
{
Assert.Equal(streamResults[i], eventResults[i]);
}
}
// ─── Different periods produce different results ───
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var bars = MakeBars(100);
var td4 = new TdSeq(4);
var td2 = new TdSeq(2);
bool anyDiff = false;
foreach (var b in bars)
{
double v4 = td4.Update(b).Value;
double v2 = td2.Update(b).Value;
if (v4 != v2) { anyDiff = true; }
}
Assert.True(anyDiff, "Period 4 and period 2 should produce different results on real data");
}
}
+322
View File
@@ -0,0 +1,322 @@
// TD_SEQ: TD Sequential
// Tom DeMark's exhaustion counting system — two-phase state machine.
// Phase 1 (Setup): counts consecutive closes vs close[comparePeriod]; ±9 completes.
// Phase 2 (Countdown): non-consecutive close vs high[2]/low[2]; ±13 completes.
// All state is O(1) scalars — no circular buffers required.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// TD_SEQ: TD Sequential
/// </summary>
/// <remarks>
/// Tom DeMark's exhaustion counting system that identifies potential trend reversals
/// through two phases:
/// <list type="bullet">
/// <item>Phase 1 — Setup (±1 to ±9): consecutive closes vs close[comparePeriod].
/// Positive = sell setup, negative = buy setup. Completes at ±9.</item>
/// <item>Phase 2 — Countdown (±1 to ±13): non-consecutive close vs high/low[2].
/// Begins after a completed setup. Completes at ±13.</item>
/// </list>
/// All state maintained in O(1) scalar variables — no buffers needed beyond
/// a small fixed history ring for close[comparePeriod], high[2], and low[2].
/// <para>
/// References:
/// DeMark, T.R. (1994). The New Science of Technical Analysis. Wiley.
/// PineScript reference: td_seq.pine
/// </para>
/// </remarks>
[SkipLocalsInit]
public sealed class TdSeq : ITValuePublisher
{
private readonly int _comparePeriod;
private readonly int _closeSize; // = comparePeriod + 1
// Close history ring: stores last (comparePeriod+1) values so we can read close[comparePeriod]
private readonly double[] _closeHist;
private readonly double[] _closeSnap;
private int _closeIdx; // next write slot
private int _closeCount; // how many slots filled (0.._closeSize)
private int _closeIdxSnap;
private int _closeCountSnap;
// High/Low history ring: stores last 3 values for high[2] / low[2]
private readonly double[] _highHist;
private readonly double[] _lowHist;
private readonly double[] _highSnap;
private readonly double[] _lowSnap;
private int _hlIdx; // next write slot (mod 3)
private int _hlCount; // how many slots filled (0..3)
private int _hlIdxSnap;
private int _hlCountSnap;
[StructLayout(LayoutKind.Auto)]
private record struct State(
int SetupCount,
int CountdownCount,
int CountdownDir,
bool SetupComplete,
double LastValidClose,
double LastValidHigh,
double LastValidLow);
private State _s;
private State _ps;
private readonly TBarPublishedHandler _barHandler;
/// <summary>Display name of the indicator.</summary>
public string Name { get; }
/// <summary>Bars required before Phase 1 produces valid output.</summary>
public int WarmupPeriod { get; }
/// <summary>True once enough close history exists to compare close[comparePeriod].</summary>
public bool IsHot => _closeCount > _comparePeriod;
/// <summary>Current setup count (9..+9). Positive = sell setup, negative = buy setup.</summary>
public int Setup => _s.SetupCount;
/// <summary>Current countdown count (13..+13). Non-zero only after a completed setup.</summary>
public int Countdown => _s.CountdownCount;
/// <summary>Last published TValue. Value = countdown when active; setup otherwise.</summary>
public TValue Last { get; private set; }
/// <inheritdoc cref="ITValuePublisher.Pub"/>
public event TValuePublishedHandler? Pub;
/// <summary>Creates TD Sequential with the specified compare period.</summary>
/// <param name="comparePeriod">Bars back for setup comparison (default 4, must be &gt; 0)</param>
public TdSeq(int comparePeriod = 4)
{
if (comparePeriod <= 0)
{
throw new ArgumentException("Compare period must be greater than 0", nameof(comparePeriod));
}
_comparePeriod = comparePeriod;
_closeSize = comparePeriod + 1;
_closeHist = new double[_closeSize];
_closeSnap = new double[_closeSize];
_highHist = new double[3];
_lowHist = new double[3];
_highSnap = new double[3];
_lowSnap = new double[3];
Name = $"TdSeq({comparePeriod})";
WarmupPeriod = comparePeriod + 1;
_barHandler = HandleBar;
}
/// <summary>Creates TD Sequential subscribed to a bar publisher.</summary>
public TdSeq(TBarSeries source, int comparePeriod = 4) : this(comparePeriod)
{
source.Pub += _barHandler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
/// <summary>
/// Processes a bar and returns the current indicator value.
/// </summary>
/// <param name="input">OHLCV bar (Close for setup, High/Low for countdown)</param>
/// <param name="isNew">True to advance state; false to rewrite the current bar</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
// Sanitize inputs — substitute last-valid on non-finite
double close = double.IsFinite(input.Close) ? input.Close : _s.LastValidClose;
double high = double.IsFinite(input.High) ? input.High : _s.LastValidHigh;
double low = double.IsFinite(input.Low) ? input.Low : _s.LastValidLow;
if (isNew)
{
// Snapshot before mutation
_ps = _s;
Array.Copy(_closeHist, _closeSnap, _closeSize);
Array.Copy(_highHist, _highSnap, 3);
Array.Copy(_lowHist, _lowSnap, 3);
_closeIdxSnap = _closeIdx;
_closeCountSnap = _closeCount;
_hlIdxSnap = _hlIdx;
_hlCountSnap = _hlCount;
// Advance close ring
_closeHist[_closeIdx] = close;
_closeIdx = (_closeIdx + 1) % _closeSize;
if (_closeCount < _closeSize) { _closeCount++; }
// Advance hi/lo ring
_highHist[_hlIdx] = high;
_lowHist[_hlIdx] = low;
_hlIdx = (_hlIdx + 1) % 3;
if (_hlCount < 3) { _hlCount++; }
}
else
{
// Rollback rings to snapshot
_s = _ps;
Array.Copy(_closeSnap, _closeHist, _closeSize);
Array.Copy(_highSnap, _highHist, 3);
Array.Copy(_lowSnap, _lowHist, 3);
_closeIdx = _closeIdxSnap;
_closeCount = _closeCountSnap;
_hlIdx = _hlIdxSnap;
_hlCount = _hlCountSnap;
// Re-write newest slots with corrected values
int newestClose = ((_closeIdx - 1) + _closeSize) % _closeSize;
_closeHist[newestClose] = close;
int newestHl = ((_hlIdx - 1) + 3) % 3;
_highHist[newestHl] = high;
_lowHist[newestHl] = low;
}
// Track last-valid prices for NaN substitution
if (double.IsFinite(input.Close)) { _s.LastValidClose = close; }
if (double.IsFinite(input.High)) { _s.LastValidHigh = high; }
if (double.IsFinite(input.Low)) { _s.LastValidLow = low; }
if (!IsHot)
{
Last = new TValue(input.Time, 0.0);
PubEvent(Last, isNew);
return Last;
}
// close[comparePeriod] = the oldest entry in the close ring:
// after writing, _closeIdx points to the NEXT write slot.
// That slot holds the oldest value (it is _comparePeriod bars ago).
double prevClose = _closeHist[_closeIdx % _closeSize];
// --- Phase 1: Setup counting ---
State s = _s;
int newSetup;
if (close < prevClose)
{
newSetup = s.SetupCount < 0 ? s.SetupCount - 1 : -1;
}
else if (close > prevClose)
{
newSetup = s.SetupCount > 0 ? s.SetupCount + 1 : 1;
}
else
{
newSetup = 0;
}
if (newSetup > 9) { newSetup = 9; }
if (newSetup < -9) { newSetup = -9; }
// Detect completed setup (first time reaching ±9)
if (Math.Abs(newSetup) == 9 && !s.SetupComplete)
{
s.SetupComplete = true;
s.CountdownCount = 0;
s.CountdownDir = newSetup > 0 ? 1 : -1;
}
// Clear setupComplete if streak broke or reversed
if (Math.Abs(newSetup) < Math.Abs(s.SetupCount) ||
(newSetup > 0 && s.SetupCount < 0) ||
(newSetup < 0 && s.SetupCount > 0))
{
s.SetupComplete = false;
}
s.SetupCount = newSetup;
// --- Phase 2: Countdown (non-consecutive) ---
if (s.CountdownDir != 0 && _hlCount >= 3)
{
// high[2] and low[2] = oldest entry in the 3-element hi/lo ring
// After writing, _hlIdx points to the next write slot = oldest slot
int oldestHl = _hlIdx % 3;
double high2 = _highHist[oldestHl];
double low2 = _lowHist[oldestHl];
if (s.CountdownDir == -1 && close < low2)
{
s.CountdownCount--;
}
else if (s.CountdownDir == 1 && close > high2)
{
s.CountdownCount++;
}
if (Math.Abs(s.CountdownCount) >= 13)
{
s.CountdownCount = s.CountdownDir == 1 ? 13 : -13;
s.CountdownDir = 0;
}
// Opposite ±9 setup resets countdown
if ((s.CountdownDir == 1 && newSetup == -9) ||
(s.CountdownDir == -1 && newSetup == 9))
{
s.CountdownCount = 0;
s.CountdownDir = newSetup > 0 ? 1 : -1;
}
}
_s = s;
// Output: countdown value when active; setup value otherwise
double result = (double)(_s.CountdownDir != 0 ? _s.CountdownCount : _s.SetupCount);
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
/// <summary>Resets all state and history to zero.</summary>
public void Reset()
{
_s = default;
_ps = default;
Array.Clear(_closeHist);
Array.Clear(_closeSnap);
Array.Clear(_highHist);
Array.Clear(_lowHist);
Array.Clear(_highSnap);
Array.Clear(_lowSnap);
_closeIdx = 0;
_closeCount = 0;
_closeIdxSnap = 0;
_closeCountSnap = 0;
_hlIdx = 0;
_hlCount = 0;
_hlIdxSnap = 0;
_hlCountSnap = 0;
Last = default;
}
/// <summary>
/// Calculates TD Sequential for an entire bar series.
/// </summary>
/// <param name="source">Source bar series</param>
/// <param name="comparePeriod">Bars back for setup comparison (default 4)</param>
/// <returns>TSeries containing the combined setup/countdown output per bar</returns>
public static TSeries Calculate(TBarSeries source, int comparePeriod = 4)
{
var indicator = new TdSeq(comparePeriod);
int len = source.Count;
var results = new TSeries();
for (int i = 0; i < len; i++)
{
results.Add(indicator.Update(source[i], isNew: true));
}
return results;
}
}