mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 02:58:05 +00:00
Refactor documentation links in numerics, oscillators, reversals, and statistics modules to use relative paths; update Bias class to handle division by zero more robustly; remove obsolete CUMMEAN Pine script; enhance trend indicators documentation; add Visual Studio Code workspace configuration.
This commit is contained in:
@@ -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 - 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,69 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class CgIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 10;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Cg _cg = null!;
|
||||
private readonly LineSeries _series;
|
||||
private readonly LineSeries _zeroLine;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"CG ({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/cg/Cg.Quantower.cs";
|
||||
|
||||
public CgIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "CG - Center of Gravity";
|
||||
Description = "Ehlers' Center of Gravity oscillator identifies potential turning points using weighted center of mass";
|
||||
|
||||
_series = new LineSeries(name: "CG", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
|
||||
_zeroLine = new LineSeries(name: "Zero", color: Color.Gray, width: 1, style: LineStyle.Dash);
|
||||
AddLineSeries(_series);
|
||||
AddLineSeries(_zeroLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_cg = new Cg(Period);
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _priceSelector(item);
|
||||
var time = this.HistoricalData.Time();
|
||||
|
||||
var input = new TValue(time, value);
|
||||
TValue result = _cg.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _cg.IsHot, ShowColdValues);
|
||||
_zeroLine.SetValue(0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
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.Calculate(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
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using Xunit;
|
||||
|
||||
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.Calculate(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.Calculate(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
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// CG: Center of Gravity - Ehlers' oscillator that identifies potential turning points
|
||||
/// in a time series by calculating the weighted center of mass of prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Center of Gravity indicator, developed by John Ehlers, oscillates around zero
|
||||
/// and provides early signals of potential reversals. It leads price movement,
|
||||
/// making it useful for timing entries and exits.
|
||||
///
|
||||
/// Formula:
|
||||
/// num = Σ(count * price[count-1]) for count = 1 to length
|
||||
/// den = Σ(price[count-1]) for count = 1 to length
|
||||
/// CG = (num / den) - (length + 1) / 2
|
||||
///
|
||||
/// Properties:
|
||||
/// - Oscillates around zero
|
||||
/// - Leads price movement (low lag)
|
||||
/// - Positive values suggest downward pressure
|
||||
/// - Negative values suggest upward pressure
|
||||
/// - Zero crossings can signal turning points
|
||||
///
|
||||
/// Key Insight:
|
||||
/// When prices are higher at the beginning of the window, CG is negative.
|
||||
/// When prices are higher at the end of the window, CG is positive.
|
||||
/// The indicator essentially measures where the "weight" of prices is concentrated.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Cg : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
// Running sums for O(1) updates
|
||||
private double _weightedSum;
|
||||
private double _sum;
|
||||
|
||||
// Snapshot state for bar correction
|
||||
private double _p_weightedSum;
|
||||
private double _p_sum;
|
||||
|
||||
private int _updateCount;
|
||||
private const int ResyncInterval = 1000;
|
||||
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Center of Gravity indicator.
|
||||
/// </summary>
|
||||
/// <param name="period">The lookback period for calculating CG (must be > 0).</param>
|
||||
public Cg(int period = 10)
|
||||
{
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(period), "Period must be at least 1.");
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Cg({period})";
|
||||
WarmupPeriod = period;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chained Center of Gravity indicator.
|
||||
/// </summary>
|
||||
/// <param name="source">The source indicator to chain from.</param>
|
||||
/// <param name="period">The lookback period.</param>
|
||||
public Cg(ITValuePublisher source, int period = 10) : this(period)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
source.Pub += HandleInput;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleInput(object? sender, in TValueEventArgs e)
|
||||
{
|
||||
Update(e.Value, e.IsNew);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double value = input.Value;
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = _buffer.Count > 0 ? _buffer.Newest : 0;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
// Snapshot state for rollback
|
||||
_p_weightedSum = _weightedSum;
|
||||
_p_sum = _sum;
|
||||
_buffer.Snapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore state from snapshot
|
||||
_weightedSum = _p_weightedSum;
|
||||
_sum = _p_sum;
|
||||
_buffer.Restore();
|
||||
}
|
||||
|
||||
// Add new value to buffer
|
||||
_buffer.Add(value);
|
||||
|
||||
// Recalculate running sums
|
||||
// Since the weights change position as we add values, we need to recalculate
|
||||
// after each update (or track differential updates which is complex)
|
||||
RecalculateSums();
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_updateCount++;
|
||||
if (_updateCount % ResyncInterval == 0)
|
||||
{
|
||||
RecalculateSums(); // Already done above, but keeps pattern consistent
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate CG
|
||||
double cg = CalculateCg();
|
||||
|
||||
Last = new TValue(input.Time, cg);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
Batch(source.Values, vSpan, _period);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Prime state with last 'period' values
|
||||
int primeStart = Math.Max(0, len - _period);
|
||||
for (int i = primeStart; i < len; i++)
|
||||
{
|
||||
Update(source[i]);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void RecalculateSums()
|
||||
{
|
||||
int n = _buffer.Count;
|
||||
_weightedSum = 0;
|
||||
_sum = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double price = _buffer[i];
|
||||
int weight = i + 1; // count = 1 to length (1-based weighting)
|
||||
_weightedSum += weight * price;
|
||||
_sum += price;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double CalculateCg()
|
||||
{
|
||||
int n = _buffer.Count;
|
||||
if (n == 0 || _sum == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// CG = (weightedSum / sum) - (n + 1) / 2
|
||||
double centerOfMass = _weightedSum / _sum;
|
||||
double midpoint = (n + 1) / 2.0;
|
||||
return centerOfMass - midpoint;
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_weightedSum = 0;
|
||||
_sum = 0;
|
||||
_p_weightedSum = 0;
|
||||
_p_sum = 0;
|
||||
_updateCount = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
foreach (double value in source)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, value));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates CG for a time series.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, int period = 10)
|
||||
{
|
||||
var cg = new Cg(period);
|
||||
return cg.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates CG in-place using a pre-allocated output span.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 10)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(period), "Period must be at least 1.");
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CalculateScalarCore(source, output, period);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
int len = source.Length;
|
||||
|
||||
const int StackAllocThreshold = 256;
|
||||
Span<double> buffer = period <= StackAllocThreshold
|
||||
? stackalloc double[period]
|
||||
: new double[period];
|
||||
|
||||
int bufferIndex = 0;
|
||||
int bufferCount = 0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = bufferCount > 0 ? buffer[(bufferIndex - 1 + period) % period] : 0;
|
||||
}
|
||||
|
||||
// Add to circular buffer
|
||||
if (bufferCount < period)
|
||||
{
|
||||
buffer[bufferCount] = val;
|
||||
bufferCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[bufferIndex] = val;
|
||||
bufferIndex = (bufferIndex + 1) % period;
|
||||
}
|
||||
|
||||
// Calculate CG for current window
|
||||
if (bufferCount == 0)
|
||||
{
|
||||
output[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double weightedSum = 0;
|
||||
double sum = 0;
|
||||
|
||||
// Calculate sums over the current buffer
|
||||
int effectiveStart = bufferCount < period ? 0 : bufferIndex;
|
||||
|
||||
for (int j = 0; j < bufferCount; j++)
|
||||
{
|
||||
int idx = (effectiveStart + j) % period;
|
||||
double price = buffer[idx];
|
||||
int weight = j + 1; // 1-based weighting
|
||||
weightedSum += weight * price;
|
||||
sum += price;
|
||||
}
|
||||
|
||||
if (sum == 0)
|
||||
{
|
||||
output[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double centerOfMass = weightedSum / sum;
|
||||
double midpoint = (bufferCount + 1) / 2.0;
|
||||
output[i] = centerOfMass - midpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
-98
@@ -1,138 +1,219 @@
|
||||
# CG: Center of Gravity
|
||||
|
||||
## Overview and Purpose
|
||||
> "The market's center of mass reveals where momentum shifts before price does."
|
||||
|
||||
The Center of Gravity (CG) indicator, developed by John Ehlers, is a cycle analysis tool that uses the physics concept of center of gravity to identify cycle turning points in financial markets. By calculating the balance point of price data over a specified period, the indicator creates an oscillator that can help traders anticipate potential reversal points in market cycles.
|
||||
The Center of Gravity (CG) oscillator, developed by John Ehlers, identifies potential turning points in price action using the physics concept of weighted center of mass. It leads price movement, making it particularly useful for timing entries and exits before traditional indicators signal.
|
||||
|
||||
Unlike traditional moving averages that simply smooth price data, the Center of Gravity indicator treats price data as masses distributed over time and calculates where the "balance point" would be. This approach provides insights into the distribution of price momentum within the lookback period.
|
||||
## Historical Context
|
||||
|
||||
## Core Concepts
|
||||
John Ehlers introduced the Center of Gravity oscillator in his 2002 book "Cybernetic Analysis for Stocks and Futures." Ehlers, an electrical engineer turned trader, applied signal processing concepts to financial markets, creating indicators with minimal lag.
|
||||
|
||||
* **Physics-based approach:** Uses the center of gravity concept from physics where each price point represents a mass and the indicator finds the balance point
|
||||
* **Oscillating indicator:** Provides an oscillator that fluctuates around zero based on price distribution
|
||||
* **Cycle identification:** Particularly effective at identifying shifts in the dominant cycle within the lookback period
|
||||
* **Zero-line analysis:** Oscillates around zero with crossovers indicating potential cycle phase changes
|
||||
The CG oscillator draws from physics: just as the center of gravity of an object determines its balance point, the CG of price determines where momentum is concentrated. When prices cluster toward recent values (uptrend), CG is positive; when prices cluster toward older values (downtrend), CG is negative.
|
||||
|
||||
The core innovation of this indicator is its ability to measure where the "weight" of price data is concentrated within the lookback period, providing insights into market momentum distribution.
|
||||
Unlike momentum oscillators that react to price changes, CG measures the distribution of price within the lookback window, providing leading rather than lagging signals.
|
||||
|
||||
## Common Settings and Parameters
|
||||
## Architecture & Physics
|
||||
|
||||
| Parameter | Default | Function | When to Adjust |
|
||||
| ------ | ------ | ------ | ------ |
|
||||
| Length | 10 | Controls the lookback period for the Center of Gravity calculation | Increase for longer cycles and smoother signals, decrease for shorter cycles and more responsive signals |
|
||||
| Source | source | Data source for calculation | Typically uses close; hlc3 provides balanced representation; hl2 for range-based analysis |
|
||||
The CG indicator uses a sliding window (RingBuffer) to maintain price history and calculates a weighted center of mass that oscillates around zero.
|
||||
|
||||
**Pro Tip:** The optimal length setting often correlates with the dominant cycle length in the market. Start with shorter periods (8-14) for active markets and longer periods (20-30) for smoother, longer-term cycle identification.
|
||||
### Core Components
|
||||
|
||||
## Calculation and Mathematical Foundation
|
||||
1. **RingBuffer**: Maintains the sliding window of `period` values
|
||||
2. **Weighted Sum (Numerator)**: Sum of position-weighted prices
|
||||
3. **Simple Sum (Denominator)**: Sum of all prices in window
|
||||
4. **Center Offset**: Subtracts the midpoint to center oscillation at zero
|
||||
|
||||
**Simplified explanation:**
|
||||
The Center of Gravity calculates where the "balance point" would be if each price in the lookback period was treated as a mass at its time position. The result is then normalized to oscillate around zero by subtracting the theoretical center point.
|
||||
### Calculation Flow
|
||||
|
||||
**Technical formula:**
|
||||
The Center of Gravity is calculated as:
|
||||
For each update:
|
||||
1. Add new price to buffer
|
||||
2. Compute weighted sum: Σ(position × price)
|
||||
3. Compute simple sum: Σ(price)
|
||||
4. Calculate center: weighted_sum / simple_sum
|
||||
5. Subtract midpoint: result - (period + 1) / 2
|
||||
|
||||
CG = [Σ(i × Price[i-1]) / Σ(Price[i-1])] - (Length + 1) / 2
|
||||
## Mathematical Foundation
|
||||
|
||||
Where:
|
||||
* i ranges from 1 to Length (representing position weights)
|
||||
* Price[i-1] is the price at position i-1 bars ago (current bar when i=1)
|
||||
* The subtraction of (Length + 1) / 2 centers the oscillator around zero
|
||||
* This represents the "balance point" where price data would be in equilibrium
|
||||
### Center of Gravity Formula
|
||||
|
||||
The calculation process:
|
||||
```
|
||||
numerator = Σ(i × Price[i-1]) for i = 1 to Length
|
||||
denominator = Σ(Price[i-1]) for i = 1 to Length
|
||||
raw_cg = numerator / denominator
|
||||
CG = raw_cg - (Length + 1) / 2
|
||||
```
|
||||
The CG at time $t$ is calculated as:
|
||||
|
||||
> 🔍 **Technical Note:** The algorithm calculates the weighted average position of prices, then subtracts the theoretical center point to create an oscillator. When prices are distributed evenly, CG equals zero. When recent prices dominate, CG becomes positive; when older prices dominate, CG becomes negative.
|
||||
$$ CG_t = \frac{\sum_{i=1}^{n} i \cdot P_{t-n+i}}{\sum_{i=1}^{n} P_{t-n+i}} - \frac{n + 1}{2} $$
|
||||
|
||||
## Interpretation Details
|
||||
where:
|
||||
|
||||
The Center of Gravity indicator provides several analytical perspectives:
|
||||
- $n$ is the period (lookback length)
|
||||
- $P_{t-n+i}$ is the price at position $i$ within the window
|
||||
- $i$ ranges from 1 (oldest) to $n$ (newest)
|
||||
|
||||
* **Zero-line crossovers:**
|
||||
* Crossing above zero: Suggests recent prices have more weight (potential upward momentum)
|
||||
* Crossing below zero: Suggests older prices have more weight (potential downward momentum)
|
||||
* Multiple crossovers may indicate choppy, non-trending conditions
|
||||
### Numerator (Weighted Sum)
|
||||
|
||||
* **Extreme readings:**
|
||||
* High positive values: Recent prices significantly outweigh older prices
|
||||
* High negative values: Older prices significantly outweigh recent prices
|
||||
* The magnitude indicates the strength of the price distribution bias
|
||||
$$ Num = \sum_{i=1}^{n} i \cdot P_i = 1 \cdot P_1 + 2 \cdot P_2 + \ldots + n \cdot P_n $$
|
||||
|
||||
* **Divergence analysis:**
|
||||
* Bullish divergence: Price makes lower lows while CG makes higher lows
|
||||
* Bearish divergence: Price makes higher highs while CG makes lower highs
|
||||
* These divergences can indicate potential shifts in price momentum
|
||||
Recent prices (higher $i$) contribute more to the weighted sum.
|
||||
|
||||
* **Mean reversion characteristics:**
|
||||
* CG tends to oscillate around zero over time
|
||||
* Extreme readings often precede moves back toward the center line
|
||||
* Can be used to identify potential reversal points
|
||||
### Denominator (Simple Sum)
|
||||
|
||||
## Limitations and Considerations
|
||||
$$ Den = \sum_{i=1}^{n} P_i $$
|
||||
|
||||
* **Market conditions:** Most effective in cyclical markets; may provide less clear signals during strong trending periods
|
||||
* **Whipsaw potential:** Can generate false signals during low-volatility, range-bound conditions
|
||||
* **Parameter sensitivity:** Length setting significantly affects responsiveness and noise levels
|
||||
* **Interpretation complexity:** Requires understanding of the balance point concept for proper interpretation
|
||||
* **Complementary tools:** Best used with trend identification tools and volume confirmation for optimal results
|
||||
### Center with Zero Offset
|
||||
|
||||
The Center of Gravity works best when combined with other cycle analysis tools and should be part of a broader trading system that includes trend and momentum confirmation.
|
||||
$$ CG = \begin{cases}
|
||||
\frac{Num}{Den} - \frac{n + 1}{2} & \text{if } Den \neq 0 \\
|
||||
0 & \text{if } Den = 0
|
||||
\end{cases} $$
|
||||
|
||||
The subtraction of $(n + 1) / 2$ centers the oscillator at zero. Without this offset, CG would oscillate around the midpoint value.
|
||||
|
||||
### Properties
|
||||
|
||||
- **Range**: Approximately $\pm \frac{n-1}{2}$ depending on price distribution
|
||||
- **Zero Crossing**: Indicates potential trend reversal
|
||||
- **Positive Values**: Recent prices dominate (uptrend momentum)
|
||||
- **Negative Values**: Older prices dominate (downtrend momentum)
|
||||
- **Zero Value**: Prices evenly distributed (neutral momentum)
|
||||
|
||||
### Example Calculation
|
||||
|
||||
For prices [10, 12, 11, 13, 15] with period 5:
|
||||
|
||||
$$
|
||||
Num = 1 \times 10 + 2 \times 12 + 3 \times 11 + 4 \times 13 + 5 \times 15 = 194
|
||||
$$
|
||||
|
||||
$$
|
||||
Den = 10 + 12 + 11 + 13 + 15 = 61
|
||||
$$
|
||||
|
||||
$$
|
||||
CG = \frac{194}{61} - \frac{5 + 1}{2} = 3.180 - 3.0 = 0.180
|
||||
$$
|
||||
|
||||
The positive value indicates recent prices are weighted higher (uptrend bias).
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, per Bar)
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Throughput** | ~15 ns/bar | O(1) with running sums |
|
||||
| **Allocations** | 0 | Zero-allocation in hot path |
|
||||
| **Complexity** | O(1) streaming | Recalculation O(N) on bar correction |
|
||||
| **Accuracy** | 10 | Exact calculation, no approximations |
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| ADD/SUB | n+1 | 1 | n+1 |
|
||||
| MUL | n | 3 | 3n |
|
||||
| DIV | 1 | 15 | 15 |
|
||||
| **Total** | **2n+2** | — | **~4n+16 cycles** |
|
||||
### Operation Count (per update)
|
||||
|
||||
*Where n = Length (default 10)*
|
||||
|
||||
**Default (n=10):** ~56 cycles per bar
|
||||
|
||||
**Breakdown:**
|
||||
- Weighted sum Σ(i × price): n MUL + (n-1) ADD = 40 cycles
|
||||
- Price sum Σ(price): (n-1) ADD = 9 cycles
|
||||
- Division + centering: 1 DIV + 1 SUB = 16 cycles
|
||||
|
||||
### Complexity Analysis
|
||||
|
||||
| Mode | Complexity | Notes |
|
||||
| Operation | Count | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| Streaming | O(n) | Full window iteration required (position weights) |
|
||||
| Batch | O(n×m) | n = length, m = bars |
|
||||
|
||||
**Memory**: ~n×8 bytes (price buffer for lookback)
|
||||
|
||||
### SIMD Analysis
|
||||
|
||||
| Optimization | Applicable | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| AVX2 vectorization | ✅ | Weighted sum is dot product with constant weights |
|
||||
| FMA | ✅ | `i × price + running_sum` pattern |
|
||||
| Batch parallelism | ✅ | FIR structure allows full vectorization |
|
||||
|
||||
**SIMD Speedup (AVX2):** For n=10, weighted sum reduces from 10 MUL to ~2 vector ops (~5× speedup on dot product). Pre-computed weight vector [1,2,3,...,n] enables efficient `vfmadd` chains.
|
||||
| ADD/SUB | ~6 | Running sum updates |
|
||||
| MUL | ~2 | Position weighting |
|
||||
| DIV | 2 | Center and offset calculation |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact weighted centroid calculation |
|
||||
| **Timeliness** | 7/10 | FIR introduces group delay ≈ n/2 |
|
||||
| **Overshoot** | 6/10 | Linear weights can amplify recent volatility |
|
||||
| **Smoothness** | 7/10 | Moderate smoothing from averaging |
|
||||
| **Accuracy** | 10/10 | Exact weighted average |
|
||||
| **Timeliness** | 9/10 | Leads price by design |
|
||||
| **Overshoot** | 6/10 | Can overshoot at extremes |
|
||||
| **Smoothness** | 7/10 | Some noise; often paired with signal line |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **TA-Lib** | N/A | Not available in TA-Lib |
|
||||
| **Skender** | N/A | Not available in Skender |
|
||||
| **Tulip** | N/A | Not available in Tulip |
|
||||
| **PineScript** | ✅ | Validated against original ta.cg() |
|
||||
|
||||
CG is validated through mathematical properties:
|
||||
- Constant price produces zero CG
|
||||
- Linear uptrend produces positive CG
|
||||
- Linear downtrend produces negative CG
|
||||
- Values bounded by approximately ±(period-1)/2
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Period Selection**: Too short periods produce noisy signals; too long periods reduce responsiveness. Ehlers recommended 10 as a starting point.
|
||||
|
||||
2. **Signal Line**: CG is often smoothed with a 3-period SMA signal line. Trading raw CG crossings may produce false signals.
|
||||
|
||||
3. **Zero Line Crossings**: Not all zero crossings are tradeable. Confirm with price action or additional filters.
|
||||
|
||||
4. **Trending Markets**: In strong trends, CG may stay positive/negative for extended periods. Zero crossing may not occur until trend exhaustion.
|
||||
|
||||
5. **Flat Markets**: During consolidation, CG oscillates around zero without clear direction, producing whipsaws.
|
||||
|
||||
6. **Warmup Period**: CG requires a full window (`period` values) before producing reliable signals.
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
using QuanTAlib;
|
||||
|
||||
// Create a 10-period CG indicator
|
||||
var cg = new Cg(period: 10);
|
||||
|
||||
// Update with new values
|
||||
var result = cg.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
|
||||
// Access the last calculated CG value
|
||||
Console.WriteLine($"CG: {cg.Last.Value}");
|
||||
|
||||
// Chained usage
|
||||
var source = new TSeries();
|
||||
var cgChained = new Cg(source, period: 10);
|
||||
|
||||
// Static batch calculation
|
||||
var output = Cg.Calculate(source, period: 10);
|
||||
|
||||
// Span-based calculation
|
||||
Span<double> outputSpan = stackalloc double[source.Count];
|
||||
Cg.Batch(source.Values, outputSpan, period: 10);
|
||||
```
|
||||
|
||||
## Applications
|
||||
|
||||
### Trend Reversal Detection
|
||||
|
||||
CG zero crossings often precede price reversals:
|
||||
- CG crosses above zero: potential bullish reversal
|
||||
- CG crosses below zero: potential bearish reversal
|
||||
|
||||
### Divergence Analysis
|
||||
|
||||
Like other oscillators, CG divergences from price can signal weakening trends:
|
||||
- Price makes higher high, CG makes lower high: bearish divergence
|
||||
- Price makes lower low, CG makes higher low: bullish divergence
|
||||
|
||||
### Momentum Confirmation
|
||||
|
||||
Use CG to confirm trend strength:
|
||||
- Rising CG in uptrend: momentum supporting trend
|
||||
- Falling CG in uptrend: momentum weakening, potential reversal
|
||||
|
||||
### Cycle Analysis
|
||||
|
||||
CG's leading nature makes it useful for timing cycle turns in conjunction with other Ehlers indicators.
|
||||
|
||||
## Signal Line Strategy
|
||||
|
||||
A common approach pairs CG with a trigger line:
|
||||
|
||||
```csharp
|
||||
var cg = new Cg(10);
|
||||
var trigger = new Sma(3); // 3-period smoothing of CG
|
||||
|
||||
// After updates:
|
||||
double cgValue = cg.Last.Value;
|
||||
double triggerValue = trigger.Update(cg.Last).Value;
|
||||
|
||||
// Buy when CG crosses above trigger
|
||||
// Sell when CG crosses below trigger
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
* Ehlers, J. F. (2002). *Rocket Science for Traders: Digital Signal Processing Applications*. John Wiley & Sons.
|
||||
* Ehlers, J. F. (2013). *Cycle Analytics for Traders: Advanced Technical Trading Concepts*. John Wiley & Sons.
|
||||
- Ehlers, J.F. (2002). *Cybernetic Analysis for Stocks and Futures*. Wiley.
|
||||
- Ehlers, J.F. (2001). "The Center of Gravity Oscillator." *Technical Analysis of Stocks & Commodities*.
|
||||
- TradingView PineScript Reference: ta.cg() function.
|
||||
Reference in New Issue
Block a user