docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files

- Remove 'C# Implementation Considerations' sections from 34 indicator .md files
- Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.)
- Move test files into tests/ subdirectories for consistent project structure
- Add trader-focused bullet points to indicator documentation
This commit is contained in:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
+278
View File
@@ -0,0 +1,278 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class TviIndicatorTests
{
[Fact]
public void TviIndicator_Constructor_SetsDefaults()
{
var indicator = new TviIndicator();
Assert.Equal("TVI - Trade Volume Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(2, indicator.MinHistoryDepths);
Assert.Equal(0.125, indicator.MinTick);
}
[Fact]
public void TviIndicator_ShortName_IsConstant()
{
var indicator = new TviIndicator();
Assert.Equal("TVI", indicator.ShortName);
}
[Fact]
public void TviIndicator_MinHistoryDepths_EqualsTwo()
{
var indicator = new TviIndicator();
Assert.Equal(2, indicator.MinHistoryDepths);
Assert.Equal(2, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void TviIndicator_MinTick_CanBeSet()
{
var indicator = new TviIndicator { MinTick = 0.5 };
Assert.Equal(0.5, indicator.MinTick);
}
[Fact]
public void TviIndicator_Initialize_CreatesInternalTvi()
{
var indicator = new TviIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void TviIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
// Varying close prices to trigger TVI direction changes
double close = 100 + (i % 2 == 0 ? i * 0.5 : -i * 0.25);
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, close, 100000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void TviIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105, 100000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with significant price change
indicator.HistoricalData.AddBar(now.AddMinutes(30), 105, 115, 100, 112, 80000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void TviIndicator_PriceAboveMinTick_DirectionUp_AddsVolume()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstVal = indicator.LinesSeries[0].GetValue(0);
// Second bar with price increase > minTick - direction up, adds volume
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 98, 100.5, 20000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double secondVal = indicator.LinesSeries[0].GetValue(0);
Assert.True(secondVal > firstVal, $"TVI should increase when price rises above minTick: {secondVal} vs {firstVal}");
}
[Fact]
public void TviIndicator_PriceBelowNegMinTick_DirectionDown_SubtractsVolume()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstVal = indicator.LinesSeries[0].GetValue(0);
// Second bar with price decrease > minTick - direction down, subtracts volume
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 102, 90, 99.5, 20000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double secondVal = indicator.LinesSeries[0].GetValue(0);
Assert.True(secondVal < firstVal, $"TVI should decrease when price falls below -minTick: {secondVal} vs {firstVal}");
}
[Fact]
public void TviIndicator_PriceWithinMinTick_DirectionSticky()
{
var indicator = new TviIndicator { MinTick = 1.0 }; // Large minTick for testing
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Second bar with large price increase - direction up
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 98, 105, 20000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double upVal = indicator.LinesSeries[0].GetValue(0);
// Third bar with small price change within minTick - direction stays up
indicator.HistoricalData.AddBar(now.AddMinutes(2), 105, 106, 104, 105.2, 15000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double stickyVal = indicator.LinesSeries[0].GetValue(0);
// Direction stayed up, so volume added
Assert.True(stickyVal > upVal, $"TVI direction should be sticky: {stickyVal} vs {upVal}");
}
[Fact]
public void TviIndicator_Cumulative_CorrectAccumulation()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Bar 1: close=100 -> TVI=0 (first bar, direction=1 by default)
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Bar 2: close=101 (up > minTick), volume=20000 -> TVI=+20000
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 98, 101, 20000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double afterUp = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(20000, afterUp, 1);
// Bar 3: close=99.5 (down > minTick), volume=15000 -> TVI=20000-15000=5000
indicator.HistoricalData.AddBar(now.AddMinutes(2), 101, 102, 99, 99.5, 15000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double afterDown = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(5000, afterDown, 1);
// Bar 4: close=100 (up > minTick), volume=10000 -> TVI=5000+10000=15000
indicator.HistoricalData.AddBar(now.AddMinutes(3), 99.5, 101, 99, 100, 10000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double finalVal = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(15000, finalVal, 1);
}
[Fact]
public void TviIndicator_LargeVolume_HandlesCorrectly()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Test with large volume values
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 1_000_000_000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 98, 108, 2_000_000_000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(2_000_000_000, val, 1);
}
[Fact]
public void TviIndicator_StartsAtZero()
{
var indicator = new TviIndicator { MinTick = 0.125 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First bar - TVI should be 0
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 100000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstVal = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, firstVal);
}
[Fact]
public void TviIndicator_DifferentMinTick_AffectsBehavior()
{
var now = DateTime.UtcNow;
// Indicator with small minTick
var smallTick = new TviIndicator { MinTick = 0.01 };
smallTick.Initialize();
// Indicator with large minTick
var largeTick = new TviIndicator { MinTick = 5.0 };
largeTick.Initialize();
// First bar
smallTick.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
smallTick.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
largeTick.HistoricalData.AddBar(now, 100, 105, 95, 100, 10000);
largeTick.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Second bar with price change of 0.5
smallTick.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100.5, 20000);
smallTick.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
largeTick.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100.5, 20000);
largeTick.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double smallVal = smallTick.LinesSeries[0].GetValue(0);
double largeVal = largeTick.LinesSeries[0].GetValue(0);
// Small tick: 0.5 > 0.01, direction changes -> adds volume
// Large tick: 0.5 < 5.0, direction stays same (up) -> adds volume
// Both add volume but direction logic differs
Assert.True(double.IsFinite(smallVal));
Assert.True(double.IsFinite(largeVal));
}
}
+483
View File
@@ -0,0 +1,483 @@
using Xunit;
namespace QuanTAlib.Tests;
public class TviTests
{
private const double DefaultMinTick = 0.125;
[Fact]
public void Constructor_DefaultParameters_CreatesValidIndicator()
{
var tvi = new Tvi();
Assert.Equal($"Tvi({DefaultMinTick})", tvi.Name);
Assert.Equal(2, tvi.WarmupPeriod);
Assert.False(tvi.IsHot);
}
[Fact]
public void Constructor_CustomMinTick_SetsParameter()
{
var tvi = new Tvi(minTick: 0.5);
Assert.Equal("Tvi(0.5)", tvi.Name);
}
[Fact]
public void Constructor_ZeroMinTick_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Tvi(minTick: 0));
}
[Fact]
public void Constructor_NegativeMinTick_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Tvi(minTick: -0.1));
}
[Fact]
public void Update_WithTBar_ReturnsValidValue()
{
var tvi = new Tvi();
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
var result = tvi.Update(bar);
Assert.True(double.IsFinite(result.Value));
Assert.Equal(0, result.Value); // First bar stays at zero (no comparison)
}
[Fact]
public void Update_WithTValue_ReturnsCurrentValue()
{
var tvi = new Tvi();
var value = new TValue(DateTime.UtcNow, 100);
var result = tvi.Update(value);
// TVI without volume data returns current TVI value (zero initially)
Assert.Equal(0, result.Value);
}
[Fact]
public void Update_PriceIncreasesAboveMinTick_DirectionUp_AddsVolume()
{
var tvi = new Tvi(minTick: 0.5);
var time = DateTime.UtcNow;
// First bar - establishes baseline
tvi.Update(new TBar(time, 100, 105, 95, 100, 100000));
// Second bar with price increase > minTick - direction becomes up, add volume
var result = tvi.Update(new TBar(time.AddMinutes(1), 100, 108, 98, 101, 80000)); // +1 > 0.5
Assert.Equal(80000, result.Value);
}
[Fact]
public void Update_PriceDecreasesAboveMinTick_DirectionDown_SubtractsVolume()
{
var tvi = new Tvi(minTick: 0.5);
var time = DateTime.UtcNow;
// First bar - establishes baseline
tvi.Update(new TBar(time, 100, 105, 95, 100, 100000));
// Second bar with price decrease > minTick - direction becomes down, subtract volume
var result = tvi.Update(new TBar(time.AddMinutes(1), 100, 102, 90, 99, 80000)); // -1 < -0.5
Assert.Equal(-80000, result.Value);
}
[Fact]
public void Update_PriceChangeWithinMinTick_DirectionSticky()
{
var tvi = new Tvi(minTick: 0.5);
var time = DateTime.UtcNow;
// First bar - establishes baseline
tvi.Update(new TBar(time, 100, 105, 95, 100, 100000));
// Second bar - big move up, direction = 1
tvi.Update(new TBar(time.AddMinutes(1), 100, 108, 98, 102, 80000)); // +2 > 0.5, direction = 1
Assert.Equal(80000, tvi.Last.Value);
// Third bar - small move (within minTick), direction stays 1
var result = tvi.Update(new TBar(time.AddMinutes(2), 102, 103, 101, 102.2, 50000)); // +0.2 < 0.5, sticky
Assert.Equal(80000 + 50000, result.Value); // Still adds because direction is still 1
}
[Fact]
public void Update_DirectionStickyWhenPriceFlat()
{
var tvi = new Tvi(minTick: 0.5);
var time = DateTime.UtcNow;
// First bar
tvi.Update(new TBar(time, 100, 105, 95, 100, 100000));
// Second bar - move down, direction = -1
tvi.Update(new TBar(time.AddMinutes(1), 100, 102, 90, 99, 80000)); // -1 < -0.5
Assert.Equal(-80000, tvi.Last.Value);
// Third bar - flat price, direction stays -1
var result = tvi.Update(new TBar(time.AddMinutes(2), 99, 100, 98, 99, 50000)); // 0 within ±0.5
Assert.Equal(-80000 - 50000, result.Value); // Subtracts because direction is still -1
}
[Fact]
public void Update_ConsistentUpDays_TviIncreases()
{
var tvi = new Tvi(minTick: 0.1);
var time = DateTime.UtcNow;
double price = 100;
for (int i = 0; i < 20; i++)
{
tvi.Update(new TBar(time.AddMinutes(i), price, price + 2, price - 1, price, 10000));
price += 1; // Price increasing each day by more than minTick
}
Assert.True(tvi.Last.Value > 0, $"TVI should be positive after consistent up days, was {tvi.Last.Value}");
}
[Fact]
public void Update_ConsistentDownDays_TviDecreases()
{
var tvi = new Tvi(minTick: 0.1);
var time = DateTime.UtcNow;
double price = 100;
for (int i = 0; i < 20; i++)
{
tvi.Update(new TBar(time.AddMinutes(i), price, price + 2, price - 1, price, 10000));
price -= 1; // Price decreasing each day by more than minTick
}
Assert.True(tvi.Last.Value < 0, $"TVI should be negative after consistent down days, was {tvi.Last.Value}");
}
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var tvi = new Tvi();
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
var result1 = tvi.Update(bar1, isNew: true);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 110, 800000);
var result2 = tvi.Update(bar2, isNew: true);
Assert.NotEqual(result1.Time, result2.Time);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var tvi = new Tvi();
var gbm = new GBM(seed: 42);
// Build up history
for (int i = 0; i < 20; i++)
{
tvi.Update(gbm.Next(), isNew: true);
}
// Get a new bar
var bar1 = gbm.Next();
var result1 = tvi.Update(bar1, isNew: true);
// Create a correction with different close
var bar2 = new TBar(bar1.Time, bar1.Open, bar1.High, bar1.Low, bar1.Close * 1.1, bar1.Volume);
var result2 = tvi.Update(bar2, isNew: false);
Assert.Equal(result1.Time, result2.Time);
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void Update_IterativeCorrections_RestoresState()
{
var tvi = new Tvi();
var gbm = new GBM(seed: 123);
// Build up history
for (int i = 0; i < 20; i++)
{
tvi.Update(gbm.Next(), isNew: true);
}
_ = tvi.Last.Value;
// New bar
var originalBar = gbm.Next();
tvi.Update(originalBar, isNew: true);
// Correction with same values should restore similar state
var correctionBar = originalBar;
var correctedResult = tvi.Update(correctionBar, isNew: false);
Assert.True(double.IsFinite(correctedResult.Value));
}
[Fact]
public void Update_WarmupPeriod_IsHotBecomesTrueAfterWarmup()
{
var tvi = new Tvi();
var time = DateTime.UtcNow;
Assert.False(tvi.IsHot);
tvi.Update(new TBar(time, 100, 110, 90, 105, 100000), isNew: true);
Assert.False(tvi.IsHot);
tvi.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 80000), isNew: true);
Assert.True(tvi.IsHot);
}
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var tvi = new Tvi();
var time = DateTime.UtcNow;
// Process some valid bars first
for (int i = 0; i < 10; i++)
{
tvi.Update(new TBar(time.AddMinutes(i), 100, 105, 95, 102 + i, 100000));
}
_ = tvi.Last.Value;
// Process bar with NaN volume
var nanBar = new TBar(time.AddMinutes(10), 105, 110, 100, 115, double.NaN);
var result = tvi.Update(nanBar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_ZeroVolume_HandlesGracefully()
{
var tvi = new Tvi();
var time = DateTime.UtcNow;
tvi.Update(new TBar(time, 100, 110, 90, 105, 100000));
var result = tvi.Update(new TBar(time.AddMinutes(1), 105, 115, 95, 110, 0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Reset_ClearsState()
{
var tvi = new Tvi();
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
tvi.Update(new TBar(time.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 100000), isNew: true);
}
Assert.True(tvi.IsHot);
Assert.True(double.IsFinite(tvi.Last.Value));
tvi.Reset();
Assert.False(tvi.IsHot);
Assert.Equal(default, tvi.Last);
}
[Fact]
public void BatchCalculate_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 100; i++)
{
bars.Add(gbm.Next());
}
// Streaming
var tvi = new Tvi();
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(tvi.Update(bar).Value);
}
// Batch
var batchResult = Tvi.Batch(bars);
Assert.Equal(bars.Count, batchResult.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], batchResult[i].Value, 10);
}
}
[Fact]
public void SpanCalculate_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 100; i++)
{
bars.Add(gbm.Next());
}
// Streaming
var tvi = new Tvi();
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(tvi.Update(bar).Value);
}
// Span
var price = bars.Close.Values.ToArray();
var volume = bars.Volume.Values.ToArray();
var output = new double[bars.Count];
Tvi.Batch(price, volume, output);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], output[i], 10);
}
}
[Fact]
public void SpanCalculate_InvalidLengths_ThrowsArgumentException()
{
var price = new double[100];
var volume = new double[99]; // Different length
var output = new double[100];
Assert.Throws<ArgumentException>(() => Tvi.Batch(price, volume, output));
}
[Fact]
public void SpanCalculate_InvalidMinTick_ThrowsArgumentException()
{
var price = new double[100];
var volume = new double[100];
var output = new double[100];
Assert.Throws<ArgumentException>(() => Tvi.Batch(price, volume, output, minTick: 0));
Assert.Throws<ArgumentException>(() => Tvi.Batch(price, volume, output, minTick: -1));
}
[Fact]
public void SpanCalculate_EmptyInput_HandlesGracefully()
{
var price = Array.Empty<double>();
var volume = Array.Empty<double>();
var output = Array.Empty<double>();
Tvi.Batch(price, volume, output);
Assert.Empty(output);
}
[Fact]
public void Event_PubFiresOnUpdate()
{
var tvi = new Tvi();
TValue? receivedValue = null;
bool receivedIsNew = false;
tvi.Pub += (object? sender, in TValueEventArgs args) =>
{
receivedValue = args.Value;
receivedIsNew = args.IsNew;
};
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000000);
tvi.Update(bar, isNew: true);
Assert.NotNull(receivedValue);
Assert.True(receivedIsNew);
}
[Fact]
public void LargeDataset_HandlesWithoutError()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 10000; i++)
{
bars.Add(gbm.Next());
}
var tvi = new Tvi();
foreach (var bar in bars)
{
var result = tvi.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
Assert.True(tvi.IsHot);
}
[Fact]
public void FormulaVerification_ManualCalculation()
{
// Manual verification of TVI formula with known values
var tvi = new Tvi(minTick: 0.5);
var time = DateTime.UtcNow;
// Bar 1: baseline (close = 100, volume = 10000)
tvi.Update(new TBar(time, 100, 105, 95, 100, 10000));
Assert.Equal(0, tvi.Last.Value); // First bar, TVI starts at 0
// Bar 2: price up by 2 (>0.5), direction = 1, add volume
// Expected: TVI = 0 + 15000 = 15000
tvi.Update(new TBar(time.AddMinutes(1), 100, 110, 95, 102, 15000));
Assert.Equal(15000, tvi.Last.Value);
// Bar 3: price down by 3 (<-0.5), direction = -1, subtract volume
// Expected: TVI = 15000 - 12000 = 3000
tvi.Update(new TBar(time.AddMinutes(2), 102, 103, 98, 99, 12000));
Assert.Equal(3000, tvi.Last.Value);
// Bar 4: price up by 0.2 (within ±0.5), direction stays -1, subtract volume
// Expected: TVI = 3000 - 20000 = -17000
tvi.Update(new TBar(time.AddMinutes(3), 99, 100, 98, 99.2, 20000));
Assert.Equal(-17000, tvi.Last.Value);
// Bar 5: price up by 3 (>0.5), direction = 1, add volume
// Expected: TVI = -17000 + 8000 = -9000
tvi.Update(new TBar(time.AddMinutes(4), 99.2, 105, 99, 102.2, 8000));
Assert.Equal(-9000, tvi.Last.Value);
}
[Fact]
public void DifferentMinTicks_ProduceDifferentResults()
{
var time = DateTime.UtcNow;
var bars = new List<TBar>
{
new(time, 100, 105, 95, 100, 10000),
new(time.AddMinutes(1), 100, 101, 99, 100.3, 15000), // +0.3
new(time.AddMinutes(2), 100.3, 101, 99, 100.1, 12000), // -0.2
new(time.AddMinutes(3), 100.1, 102, 99, 101, 8000), // +0.9
};
// With minTick = 0.1: all moves register
var tvi01 = new Tvi(minTick: 0.1);
foreach (var bar in bars)
{
tvi01.Update(bar);
}
// With minTick = 0.5: only large moves register
var tvi05 = new Tvi(minTick: 0.5);
foreach (var bar in bars)
{
tvi05.Update(bar);
}
// Results should differ due to sticky direction behavior
Assert.NotEqual(tvi01.Last.Value, tvi05.Last.Value);
}
}
@@ -0,0 +1,196 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
namespace QuanTAlib.Tests;
public class TviValidationTests
{
private readonly ValidationTestData _data;
public TviValidationTests()
{
_data = new ValidationTestData();
}
// Note: TVI (Trade Volume Index) is not available in TA-Lib, Skender, Tulip, or Ooples.
// Validation tests focus on internal consistency between streaming, batch, and span modes.
[Fact]
public void Tvi_Streaming_Matches_Batch()
{
const double minTick = 0.125;
// Streaming
var tvi = new Tvi(minTick);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(tvi.Update(bar).Value);
}
// Batch
var batchResult = Tvi.Batch(_data.Bars, minTick);
var batchValues = batchResult.Values.ToArray();
ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-9);
}
[Fact]
public void Tvi_Span_Matches_Streaming()
{
const double minTick = 0.125;
// Streaming
var tvi = new Tvi(minTick);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(tvi.Update(bar).Value);
}
// Span
var close = _data.Bars.Close.Values.ToArray();
var volume = _data.Bars.Volume.Values.ToArray();
var spanOutput = new double[close.Length];
Tvi.Batch(close, volume, spanOutput, minTick);
ValidationHelper.VerifyData(streamingValues.ToArray(), spanOutput, 0, 100, 1e-9);
}
[Fact]
public void Tvi_Different_MinTicks_Produce_Different_Results()
{
const double minTick1 = 0.1;
const double minTick2 = 0.5;
var tvi1 = new Tvi(minTick1);
var tvi2 = new Tvi(minTick2);
var values1 = new List<double>();
var values2 = new List<double>();
foreach (var bar in _data.Bars)
{
values1.Add(tvi1.Update(bar).Value);
values2.Add(tvi2.Update(bar).Value);
}
// With different minTick values, we expect different direction changes
// leading to different cumulative values
bool foundDifference = false;
for (int i = 10; i < values1.Count; i++)
{
if (Math.Abs(values1[i] - values2[i]) > 1e-9)
{
foundDifference = true;
break;
}
}
Assert.True(foundDifference, "Different minTick values should produce different results");
}
[Fact]
public void Tvi_With_Tiny_MinTick_Behaves_Like_OBV()
{
// With very small minTick, TVI should behave similarly to OBV
// (direction changes on virtually any price change)
const double minTick = 1e-12;
var tvi = new Tvi(minTick);
var obv = new Obv();
var tviValues = new List<double>();
var obvValues = new List<double>();
foreach (var bar in _data.Bars)
{
tviValues.Add(tvi.Update(bar).Value);
obvValues.Add(obv.Update(bar).Value);
}
// With tiny minTick, TVI direction changes on any price move (like OBV)
// Note: TVI direction is sticky when price unchanged, OBV adds 0 when unchanged
// So they should match closely but may differ on exactly unchanged prices
// At minimum, verify finite values and similar magnitude
Assert.True(tviValues.All(v => double.IsFinite(v)), "TVI should produce finite values");
Assert.True(obvValues.All(v => double.IsFinite(v)), "OBV should produce finite values");
// Both should have same sign (both accumulating in same direction)
double lastTvi = tviValues[tviValues.Count - 1];
double lastObv = obvValues[obvValues.Count - 1];
if (lastTvi != 0 && lastObv != 0)
{
Assert.Equal(Math.Sign(lastTvi), Math.Sign(lastObv));
}
}
[Fact]
public void Tvi_AllModes_Match_With_Different_MinTicks()
{
double[] minTickValues = { 0.01, 0.05, 0.1, 0.25, 0.5, 1.0 };
foreach (var minTick in minTickValues)
{
// Streaming
var tvi = new Tvi(minTick);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(tvi.Update(bar).Value);
}
// Batch
var batchResult = Tvi.Batch(_data.Bars, minTick);
var batchValues = batchResult.Values.ToArray();
// Span
var close = _data.Bars.Close.Values.ToArray();
var volume = _data.Bars.Volume.Values.ToArray();
var spanOutput = new double[close.Length];
Tvi.Batch(close, volume, spanOutput, minTick);
// Verify all modes match
ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-9);
ValidationHelper.VerifyData(streamingValues.ToArray(), spanOutput, 0, 100, 1e-9);
}
}
[Fact]
public void Tvi_Cumulative_Values_Are_Finite()
{
const double minTick = 0.125;
var tvi = new Tvi(minTick);
var values = new List<double>();
foreach (var bar in _data.Bars)
{
values.Add(tvi.Update(bar).Value);
}
// All values should be finite
Assert.True(values.All(v => double.IsFinite(v)), "All TVI values should be finite");
// Values should be non-zero after warmup
Assert.True(values.Skip(10).Any(v => v != 0), "TVI should have non-zero values after warmup");
}
[Fact]
public void Tvi_MatchesOoples_Structural()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var ooplesData = bars.Select(b => new TickerData
{
Date = new DateTime(b.Time, DateTimeKind.Utc),
Open = b.Open, High = b.High, Low = b.Low,
Close = b.Close, Volume = b.Volume
}).ToList();
var result = new StockData(ooplesData).CalculateTradeVolumeIndex();
var values = result.CustomValuesList;
int finiteCount = values.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
}
}