mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 13:58:04 +00:00
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:
@@ -0,0 +1,341 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Quantower.Tests;
|
||||
|
||||
public class CgIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CgIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new CgIndicator();
|
||||
|
||||
Assert.Equal(10, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("CG - Ehlers Center of Gravity", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new CgIndicator();
|
||||
|
||||
Assert.Equal(0, CgIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 14 };
|
||||
|
||||
Assert.True(indicator.ShortName.Contains("CG", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("14", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_Initialize_CreatesInternalCg()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (CG + Zero line)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 5 };
|
||||
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 CgIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 5 };
|
||||
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 CgIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Should not throw an exception
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 105, 103, 107, 110 };
|
||||
|
||||
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 CgIndicator_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 CgIndicator { Period = 5, 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 CgIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
|
||||
Assert.Equal(10, indicator.Period);
|
||||
|
||||
indicator.Period = 20;
|
||||
Assert.Equal(20, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_Source_CanBeChanged()
|
||||
{
|
||||
var indicator = new CgIndicator { Source = SourceType.Close };
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
|
||||
indicator.Source = SourceType.Open;
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ShowColdValues_CanBeChanged()
|
||||
{
|
||||
var indicator = new CgIndicator { ShowColdValues = true };
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ShortName_UpdatesWhenPeriodChanges()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
string initialName = indicator.ShortName;
|
||||
|
||||
Assert.True(initialName.Contains("10", StringComparison.Ordinal));
|
||||
|
||||
indicator.Period = 20;
|
||||
string updatedName = indicator.ShortName;
|
||||
|
||||
Assert.True(updatedName.Contains("20", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ProcessUpdate_IgnoresNonBarUpdates()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 5 };
|
||||
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 that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_LineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var lineSeries = indicator.LinesSeries[0];
|
||||
|
||||
Assert.Equal("CG", lineSeries.Name);
|
||||
Assert.Equal(2, lineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, lineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ZeroLine_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var zeroLine = indicator.LinesSeries[1];
|
||||
|
||||
Assert.Equal("Zero", zeroLine.Name);
|
||||
Assert.Equal(1, zeroLine.Width);
|
||||
Assert.Equal(LineStyle.Dash, zeroLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 5, 10, 20, 50 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new CgIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add enough bars to fill the buffer
|
||||
for (int i = 0; i < period + 5; 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 cgValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(cgValue), $"Period {period} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_CgValuesAreBounded()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 98, 105, 97, 110, 95, 108, 92, 115, 90, 120 };
|
||||
double maxExpectedBound = (10 - 1) / 2.0 + 1.0; // Period-based bound with margin
|
||||
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 5, close - 5, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
// All CG values should be bounded based on period
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
double value = indicator.LinesSeries[0].GetValue(closes.Length - 1 - i);
|
||||
Assert.True(Math.Abs(value) <= maxExpectedBound,
|
||||
$"CG value at index {i} should be bounded ±{maxExpectedBound}, got {value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_ConstantPrice_ProducesZeroCg()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add constant price bars
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// CG should be approximately zero for constant price
|
||||
double cgValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(Math.Abs(cgValue) < 1e-9, $"Constant price should produce zero CG, got {cgValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_Uptrend_ProducesPositiveCg()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add uptrending price bars
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// CG should be positive for uptrend
|
||||
double cgValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(cgValue > 0, $"Uptrend should produce positive CG, got {cgValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CgIndicator_Downtrend_ProducesNegativeCg()
|
||||
{
|
||||
var indicator = new CgIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add downtrending price bars
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
double price = 200 - i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// CG should be negative for downtrend
|
||||
double cgValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(cgValue < 0, $"Downtrend should produce negative CG, got {cgValue}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class CgTests
|
||||
{
|
||||
private const int DefaultPeriod = 10;
|
||||
private const double Epsilon = 1e-10;
|
||||
|
||||
#region Constructor Validation
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PeriodLessThanOne_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Cg(0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters_CreatesIndicator()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
Assert.Equal("Cg(10)", cg.Name);
|
||||
Assert.Equal(10, cg.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultPeriod_IsTen()
|
||||
{
|
||||
var cg = new Cg();
|
||||
Assert.Equal("Cg(10)", cg.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSource_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new Cg(null!, 10));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var cg = new Cg(DefaultPeriod);
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
TValue result = cg.Update(input);
|
||||
Assert.True(result.Time != default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LastPropertyUpdated()
|
||||
{
|
||||
var cg = new Cg(DefaultPeriod);
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
cg.Update(input);
|
||||
Assert.Equal(input.Time, cg.Last.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantSeries_ReturnsZero()
|
||||
{
|
||||
// CG of a constant series should be close to zero
|
||||
// because all prices have equal weight contribution
|
||||
var cg = new Cg(10);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 50.0));
|
||||
}
|
||||
Assert.Equal(0, cg.Last.Value, Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IncreasingPrices_ReturnsPositive()
|
||||
{
|
||||
// When prices are higher at the end, CG should be positive
|
||||
var cg = new Cg(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i * 10));
|
||||
}
|
||||
Assert.True(cg.Last.Value > 0, $"Expected positive CG, got {cg.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_DecreasingPrices_ReturnsNegative()
|
||||
{
|
||||
// When prices are higher at the beginning, CG should be negative
|
||||
var cg = new Cg(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200 - i * 10));
|
||||
}
|
||||
Assert.True(cg.Last.Value < 0, $"Expected negative CG, got {cg.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_OscillatesAroundZero()
|
||||
{
|
||||
var cg = new Cg(20);
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
int positiveCount = 0;
|
||||
int negativeCount = 0;
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
if (cg.IsHot)
|
||||
{
|
||||
if (cg.Last.Value > 0)
|
||||
{
|
||||
positiveCount++;
|
||||
}
|
||||
else if (cg.Last.Value < 0)
|
||||
{
|
||||
negativeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CG should oscillate, having both positive and negative values
|
||||
Assert.True(positiveCount > 0, "Expected some positive values");
|
||||
Assert.True(negativeCount > 0, "Expected some negative values");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsNew Parameter (Bar Correction)
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed initial values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
double valueBeforeNew = cg.Last.Value;
|
||||
|
||||
// Update with isNew=true advances state
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 200), isNew: true);
|
||||
double valueAfterNew = cg.Last.Value;
|
||||
|
||||
// Value should change since we added a different value
|
||||
Assert.NotEqual(valueBeforeNew, valueAfterNew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_DoesNotAdvanceState()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed initial values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
// Update with isNew=true first time
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 150), isNew: true);
|
||||
double valueAfterFirstUpdate = cg.Last.Value;
|
||||
|
||||
// Update same bar with different value, isNew=false
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 160), isNew: false);
|
||||
|
||||
// Another correction
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 150), isNew: false);
|
||||
double valueAfterSecondCorrection = cg.Last.Value;
|
||||
|
||||
// Should restore to original value when corrected back
|
||||
Assert.Equal(valueAfterFirstUpdate, valueAfterSecondCorrection, Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_RestoresCorrectState()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed initial values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
// Make multiple corrections
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 200), isNew: true);
|
||||
double afterNew = cg.Last.Value;
|
||||
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 250), isNew: false);
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 300), isNew: false);
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), 200), isNew: false);
|
||||
|
||||
// Should match the value after the first isNew=true update with 200
|
||||
Assert.Equal(afterNew, cg.Last.Value, Epsilon);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warmup and IsHot
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FalseBeforeWarmup()
|
||||
{
|
||||
var cg = new Cg(20);
|
||||
|
||||
for (int i = 0; i < 19; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
Assert.False(cg.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_TrueAfterWarmup()
|
||||
{
|
||||
var cg = new Cg(20);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
Assert.True(cg.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesPeriod()
|
||||
{
|
||||
var cg = new Cg(25);
|
||||
Assert.Equal(25, cg.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN and Infinity Handling
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNInput_UsesLastValidValue()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed valid values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
// Feed NaN
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), double.NaN));
|
||||
|
||||
// Result should still be finite
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed valid values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
// Feed infinity
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15), double.PositiveInfinity));
|
||||
|
||||
// Result should still be finite
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleNaNs_StillProducesFiniteResult()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed valid values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
// Feed multiple NaNs
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(15 + i), double.NaN));
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Feed values
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
Assert.True(cg.IsHot);
|
||||
|
||||
cg.Reset();
|
||||
|
||||
Assert.False(cg.IsHot);
|
||||
Assert.Equal(default, cg.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReinitializationWithSameData()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
var inputs = new List<TValue>();
|
||||
|
||||
// Generate and store values
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
inputs.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i * 0.5));
|
||||
}
|
||||
|
||||
// First pass
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
cg.Update(input);
|
||||
}
|
||||
|
||||
double firstPassResult = cg.Last.Value;
|
||||
|
||||
// Reset and second pass
|
||||
cg.Reset();
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
cg.Update(input);
|
||||
}
|
||||
|
||||
double secondPassResult = cg.Last.Value;
|
||||
|
||||
Assert.Equal(firstPassResult, secondPassResult, Epsilon);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prime
|
||||
|
||||
[Fact]
|
||||
public void Prime_InitializesStateCorrectly()
|
||||
{
|
||||
var cg1 = new Cg(10);
|
||||
var cg2 = new Cg(10);
|
||||
|
||||
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
|
||||
|
||||
// Method 1: Use Prime
|
||||
cg1.Prime(primeData);
|
||||
|
||||
// Method 2: Update individually
|
||||
foreach (double val in primeData)
|
||||
{
|
||||
cg2.Update(new TValue(DateTime.UtcNow, val));
|
||||
}
|
||||
|
||||
Assert.Equal(cg2.Last.Value, cg1.Last.Value, Epsilon);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Chaining
|
||||
|
||||
[Fact]
|
||||
public void ChainedConstructor_ReceivesUpdates()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var cg = new Cg(source, 10);
|
||||
|
||||
// Feed values through source
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
Assert.True(cg.IsHot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AllModes Consistency (Batch vs Streaming vs Static)
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
const int period = 14;
|
||||
const int dataLen = 100;
|
||||
const int compareLen = 50;
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Mode 1: Streaming (Update one at a time)
|
||||
var streaming = new Cg(period);
|
||||
foreach (var tv in tSeries)
|
||||
{
|
||||
streaming.Update(tv);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via Update(TSeries)
|
||||
var batchIndicator = new Cg(period);
|
||||
var batchResult = batchIndicator.Update(tSeries);
|
||||
|
||||
// Mode 3: Static Calculate
|
||||
var staticResult = Cg.Batch(tSeries, period);
|
||||
|
||||
// Mode 4: Span-based Batch
|
||||
double[] sourceArray = new double[dataLen];
|
||||
double[] spanResult = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
sourceArray[i] = tSeries[i].Value;
|
||||
}
|
||||
|
||||
Cg.Batch(sourceArray, spanResult, period);
|
||||
|
||||
// Compare last 'compareLen' values (after warmup settles)
|
||||
int startIdx = dataLen - compareLen;
|
||||
for (int i = startIdx; i < dataLen; i++)
|
||||
{
|
||||
double batchVal = batchResult[i].Value;
|
||||
double staticVal = staticResult[i].Value;
|
||||
double spanVal = spanResult[i];
|
||||
|
||||
// Batch and static should match exactly
|
||||
Assert.Equal(batchVal, staticVal, Epsilon);
|
||||
|
||||
// Span should match batch
|
||||
Assert.Equal(batchVal, spanVal, Epsilon);
|
||||
}
|
||||
|
||||
// Streaming last should match batch last
|
||||
Assert.Equal(batchResult[^1].Value, streaming.Last.Value, 1e-8);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span Batch Validation
|
||||
|
||||
[Fact]
|
||||
public void Batch_MismatchedLengths_ThrowsArgumentException()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] output = new double[50];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Cg.Batch(source, output, 10));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_InvalidPeriod_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] output = new double[100];
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => Cg.Batch(source, output, 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyInput_ReturnsEmpty()
|
||||
{
|
||||
double[] source = [];
|
||||
double[] output = [];
|
||||
|
||||
// Should not throw
|
||||
Cg.Batch(source, output, 10);
|
||||
|
||||
// Verify output is empty as expected
|
||||
Assert.Empty(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ResultsAreFinite()
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
double[] source = bars.Select(b => b.Close).ToArray();
|
||||
double[] output = new double[200];
|
||||
|
||||
Cg.Batch(source, output, 20);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"CG value {val} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Period Values
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
[InlineData(50)]
|
||||
public void Update_DifferentPeriods_ProducesResults(int period)
|
||||
{
|
||||
var cg = new Cg(period);
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(cg.IsHot);
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mathematical Properties
|
||||
|
||||
[Fact]
|
||||
public void Update_BoundedByPeriod()
|
||||
{
|
||||
// CG should be bounded by approximately ±(period-1)/2
|
||||
var cg = new Cg(10);
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
double maxBound = 10.0; // Some reasonable bound
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
if (cg.IsHot)
|
||||
{
|
||||
Assert.True(Math.Abs(cg.Last.Value) < maxBound,
|
||||
$"CG value {cg.Last.Value} exceeds expected bound");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// COVERAGE TESTS: Target uncovered branches identified by OpenCover
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#region Coverage: ResyncInterval branch (Update line 121-123)
|
||||
|
||||
[Fact]
|
||||
public void Update_ResyncInterval_TriggersAtThousandUpdates()
|
||||
{
|
||||
// The ResyncInterval is 1000 — feed exactly 1000 isNew=true updates
|
||||
// to hit the _updateCount % ResyncInterval == 0 branch (line 121-123).
|
||||
var cg = new Cg(10);
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + (i % 50)), isNew: true);
|
||||
}
|
||||
|
||||
// After 1000 updates the resync path was taken; result should still be finite
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
Assert.True(cg.IsHot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Update(TSeries) empty source (line 137-138)
|
||||
|
||||
[Fact]
|
||||
public void UpdateTSeries_EmptySource_ReturnsEmptyTSeries()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
var emptySource = new TSeries();
|
||||
|
||||
TSeries result = cg.Update(emptySource);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: CalculateCg sum==0 branch (line 184-185)
|
||||
|
||||
[Fact]
|
||||
public void Update_AllZeroValues_ReturnsZero()
|
||||
{
|
||||
// When all prices are zero, _sum == 0 → CalculateCg returns 0 (line 184-185).
|
||||
var cg = new Cg(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 0.0));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, cg.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ZeroSumMixedValues_ReturnsZero()
|
||||
{
|
||||
// Values that sum to zero: e.g. +50, -50 alternating in a period=2 window.
|
||||
var cg = new Cg(2);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double val = (i % 2 == 0) ? 100.0 : -100.0;
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
|
||||
}
|
||||
|
||||
// Sum of last 2 values: 100 + (-100) = 0 → CG = 0
|
||||
Assert.Equal(0.0, cg.Last.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Calculate() tuple method (line 248-252)
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsTupleWithResultsAndIndicator()
|
||||
{
|
||||
// Covers the entire Calculate() method (lines 248-252) which was never called.
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var (results, indicator) = Cg.Calculate(tSeries, 10);
|
||||
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.True(double.IsFinite(results.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: CalculateScalarCore NaN paths (lines 271-273, 310-312)
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaNAsFirstValue_SubstitutesZero()
|
||||
{
|
||||
// When the first value is NaN and buffer is empty, val = 0 (line 271-273).
|
||||
double[] source = [double.NaN, 100.0, 200.0, 300.0, 400.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
// First value substituted with 0 → all outputs should be finite
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NaNMidStream_SubstitutesLastValid()
|
||||
{
|
||||
// When NaN appears after valid values, it substitutes the last valid value.
|
||||
double[] source = [100.0, 200.0, double.NaN, 300.0, 400.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_AllZeros_ReturnsZeroCg()
|
||||
{
|
||||
// When all values are 0, sum==0 → output = 0 (lines 310-312).
|
||||
double[] source = [0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
double[] output = new double[5];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.Equal(0.0, val);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargePeriod_UsesHeapAllocation()
|
||||
{
|
||||
// Period > 256 forces heap allocation instead of stackalloc (line 261-262).
|
||||
int period = 300;
|
||||
int len = 400;
|
||||
double[] source = new double[len];
|
||||
double[] output = new double[len];
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
source[i] = 100.0 + i;
|
||||
}
|
||||
|
||||
Cg.Batch(source, output, period);
|
||||
|
||||
// Verify results are finite after warmup
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NegativeInfinity_SubstitutesLastValid()
|
||||
{
|
||||
double[] source = [100.0, 200.0, double.NegativeInfinity, 300.0];
|
||||
double[] output = new double[4];
|
||||
|
||||
Cg.Batch(source, output, 3);
|
||||
|
||||
foreach (double val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage: Dispose (inherited from AbstractBase)
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
var cg = new Cg(10);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var ex = Record.Exception(() => cg.Dispose());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using Xunit;
|
||||
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for CG (Center of Gravity).
|
||||
/// CG is Ehlers' proprietary indicator not commonly implemented in trading libraries
|
||||
/// (TA-Lib, Skender, Tulip), so validation is done against mathematical properties
|
||||
/// and known theoretical results based on the original PineScript implementation.
|
||||
/// </summary>
|
||||
public class CgValidationTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
|
||||
#region Mathematical Property Validation
|
||||
|
||||
[Fact]
|
||||
public void Validation_CgBounds_ShouldBeWithinPeriodRange()
|
||||
{
|
||||
// CG oscillates around zero with range dependent on period
|
||||
// Maximum theoretical range is approximately ±(period-1)/2
|
||||
const int period = 10;
|
||||
var cg = new Cg(period);
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
double maxAbsValue = (period - 1) / 2.0 + 0.5; // Allow small margin
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
if (cg.IsHot)
|
||||
{
|
||||
Assert.True(Math.Abs(cg.Last.Value) <= maxAbsValue,
|
||||
$"CG value {cg.Last.Value} exceeds expected bounds ±{maxAbsValue}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ConstantSeries_CgIsZero()
|
||||
{
|
||||
// For a constant series, CG = (length+1)/2 - (length+1)/2 = 0
|
||||
// Because center of mass equals midpoint when all weights are equal
|
||||
var cg = new Cg(10);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, cg.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_LinearUptrend_CgPositive()
|
||||
{
|
||||
// For an uptrend, recent prices are higher, so center of gravity
|
||||
// shifts toward recent values, resulting in positive CG
|
||||
var cg = new Cg(10);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double price = 100.0 + i * 1.0; // Linear uptrend
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(cg.Last.Value > 0.0,
|
||||
$"Linear uptrend should produce positive CG, got {cg.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_LinearDowntrend_CgNegative()
|
||||
{
|
||||
// For a downtrend, older prices are higher, so center of gravity
|
||||
// shifts toward older values, resulting in negative CG
|
||||
var cg = new Cg(10);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double price = 200.0 - i * 1.0; // Linear downtrend
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(cg.Last.Value < 0.0,
|
||||
$"Linear downtrend should produce negative CG, got {cg.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ExponentialTrend_AmplifiedSignal()
|
||||
{
|
||||
// Exponential uptrend should produce stronger positive CG than linear
|
||||
var cgExp = new Cg(10);
|
||||
var cgLin = new Cg(10);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double expPrice = 100.0 * Math.Exp(i * 0.02);
|
||||
double linPrice = 100.0 + i * 2.0;
|
||||
cgExp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), expPrice));
|
||||
cgLin.Update(new TValue(DateTime.UtcNow.AddSeconds(i), linPrice));
|
||||
}
|
||||
|
||||
// Both should be positive, exponential trend may have different magnitude
|
||||
Assert.True(cgExp.Last.Value > 0.0, $"Exponential trend should be positive, got {cgExp.Last.Value}");
|
||||
Assert.True(cgLin.Last.Value > 0.0, $"Linear trend should be positive, got {cgLin.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ZeroCrossings_IndicateReversals()
|
||||
{
|
||||
// CG should cross zero near price reversals
|
||||
var cg = new Cg(10);
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave to simulate price oscillation
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.2);
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
if (cg.IsHot)
|
||||
{
|
||||
values.Add(cg.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Count zero crossings
|
||||
int crossings = 0;
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
if (values[i - 1] * values[i] < 0)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should have multiple zero crossings for oscillating price
|
||||
Assert.True(crossings >= 3, $"Should have multiple zero crossings, got {crossings}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PineScript Formula Verification
|
||||
|
||||
[Fact]
|
||||
public void Validation_PineScriptFormula_ManualCalculation()
|
||||
{
|
||||
// Verify against manual calculation of PineScript formula:
|
||||
// num = Σ(count * price) for count 1 to length
|
||||
// den = Σ(price) for count 1 to length
|
||||
// result = (num / den) - (length + 1) / 2
|
||||
|
||||
const int period = 5;
|
||||
double[] prices = { 10.0, 12.0, 11.0, 13.0, 15.0 };
|
||||
|
||||
// Manual calculation:
|
||||
// count=1: price[0]=10, count=2: price[1]=12, etc.
|
||||
// num = 1*10 + 2*12 + 3*11 + 4*13 + 5*15 = 10 + 24 + 33 + 52 + 75 = 194
|
||||
// den = 10 + 12 + 11 + 13 + 15 = 61
|
||||
// result = 194/61 - (5+1)/2 = 3.1803... - 3 = 0.1803...
|
||||
double expectedNum = 1 * 10 + 2 * 12 + 3 * 11 + 4 * 13 + 5 * 15;
|
||||
double expectedDen = 10 + 12 + 11 + 13 + 15;
|
||||
double expectedCg = (expectedNum / expectedDen) - (period + 1) / 2.0;
|
||||
|
||||
var cg = new Cg(period);
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
Assert.Equal(expectedCg, cg.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_DenominatorZeroCase()
|
||||
{
|
||||
// When all prices are zero, denominator is zero
|
||||
// PineScript formula: den != 0 ? num/den : (length+1)/2
|
||||
// Result = (length+1)/2 - (length+1)/2 = 0
|
||||
var cg = new Cg(10);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 0.0));
|
||||
}
|
||||
|
||||
// Should handle gracefully (not NaN/Infinity)
|
||||
Assert.True(double.IsFinite(cg.Last.Value), "CG should handle zero denominator");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming vs Batch Consistency
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(123)]
|
||||
[InlineData(999)]
|
||||
public void Validation_StreamingMatchesBatch(int seed)
|
||||
{
|
||||
const int period = 10;
|
||||
const int dataLen = 100;
|
||||
|
||||
var gbm = new GBM(seed: seed);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new Cg(period);
|
||||
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 = Cg.Batch(tSeries, period);
|
||||
|
||||
// Compare last values
|
||||
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SpanMatchesTSeries()
|
||||
{
|
||||
const int period = 14;
|
||||
const int dataLen = 200;
|
||||
|
||||
var gbm = new GBM(seed: 77);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// TSeries approach
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var tSeriesResult = Cg.Batch(tSeries, period);
|
||||
|
||||
// Span approach
|
||||
double[] source = new double[dataLen];
|
||||
double[] spanResult = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
source[i] = bars[i].Close;
|
||||
}
|
||||
|
||||
Cg.Batch(source, spanResult, period);
|
||||
|
||||
// Compare all values after warmup
|
||||
for (int i = period; i < dataLen; i++)
|
||||
{
|
||||
Assert.Equal(tSeriesResult[i].Value, spanResult[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Period Sizes
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
[InlineData(50)]
|
||||
public void Validation_DifferentPeriods_ConsistentResults(int period)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var cg = new Cg(period);
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(cg.IsHot);
|
||||
Assert.True(double.IsFinite(cg.Last.Value));
|
||||
|
||||
// CG bounds check
|
||||
double maxAbsValue = (period - 1) / 2.0 + 1.0;
|
||||
Assert.True(Math.Abs(cg.Last.Value) <= maxAbsValue,
|
||||
$"CG with period {period} should be within ±{maxAbsValue}, got {cg.Last.Value}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
public void Validation_LongerPeriod_SlowerResponse(int period)
|
||||
{
|
||||
// Longer period should have smaller magnitude changes
|
||||
var cg = new Cg(period);
|
||||
var changes = new List<double>();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
double? prevValue = null;
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
cg.Update(new TValue(bar.Time, bar.Close));
|
||||
if (cg.IsHot && prevValue.HasValue)
|
||||
{
|
||||
changes.Add(Math.Abs(cg.Last.Value - prevValue.Value));
|
||||
}
|
||||
prevValue = cg.Last.Value;
|
||||
}
|
||||
|
||||
double avgChange = changes.Average();
|
||||
Assert.True(avgChange > 0, "Should have some variance in CG values");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lead/Lag Properties
|
||||
|
||||
[Fact]
|
||||
public void Validation_CgLeadsPrice_CrossesBeforePeaks()
|
||||
{
|
||||
// CG is designed to lead price, crossing zero before peaks/troughs
|
||||
var cg = new Cg(10);
|
||||
|
||||
// Create trending then reversing data
|
||||
var prices = new List<double>();
|
||||
var cgValues = new List<double>();
|
||||
|
||||
// Uptrend
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100.0 + i * 0.5;
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
prices.Add(price);
|
||||
if (cg.IsHot)
|
||||
{
|
||||
cgValues.Add(cg.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Plateau/slight decline
|
||||
for (int i = 30; i < 50; i++)
|
||||
{
|
||||
double price = 115.0 - (i - 30) * 0.2;
|
||||
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
prices.Add(price);
|
||||
cgValues.Add(cg.Last.Value);
|
||||
}
|
||||
|
||||
// CG should show declining values as momentum slows even during uptrend
|
||||
// This tests the leading characteristic
|
||||
Assert.True(cgValues.Count > 20, "Should have enough CG values to analyze");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public void Cg_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).CalculateEhlersCenterofGravityOscillator();
|
||||
var values = result.CustomValuesList;
|
||||
int finiteCount = values.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user