Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+170
View File
@@ -0,0 +1,170 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Quantower.Tests;
public class CcorIndicatorTests
{
[Fact]
public void CcorIndicator_Constructor_SetsDefaults()
{
var indicator = new CcorIndicator();
Assert.Equal(20, indicator.Period);
Assert.Equal(9.0, indicator.Threshold);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("CCOR - Ehlers Correlation Cycle", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void CcorIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new CcorIndicator();
Assert.Equal(0, CcorIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void CcorIndicator_ShortName_IncludesPeriodAndThreshold()
{
var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
Assert.True(indicator.ShortName.Contains("CCOR", StringComparison.Ordinal));
Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal));
Assert.True(indicator.ShortName.Contains("9.0", StringComparison.Ordinal));
}
[Fact]
public void CcorIndicator_Initialize_CreatesInternalCcor()
{
var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (Real + Imag + Angle + State)
Assert.Equal(4, indicator.LinesSeries.Count);
}
[Fact]
public void CcorIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
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);
// All 4 line series should have a value
for (int s = 0; s < 4; s++)
{
Assert.Equal(1, indicator.LinesSeries[s].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[s].GetValue(0)),
$"Line series {s} should be finite");
}
}
[Fact]
public void CcorIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
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));
for (int s = 0; s < 4; s++)
{
Assert.Equal(2, indicator.LinesSeries[s].Count);
}
}
[Fact]
public void CcorIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
indicator.Initialize();
// Should not throw an exception
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
// Assert that the indicator still exists
Assert.NotNull(indicator);
}
[Fact]
public void CcorIndicator_SourceCodeLink_IsValid()
{
var indicator = new CcorIndicator();
Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink));
Assert.Contains("Ccor.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void CcorIndicator_MultipleHistoricalBars_AllFinite()
{
var indicator = new CcorIndicator { Period = 10, Threshold = 9.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double price = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price + 1);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
for (int s = 0; s < 4; s++)
{
Assert.Equal(30, indicator.LinesSeries[s].Count);
for (int i = 0; i < 30; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[s].GetValue(i)),
$"Line series {s} at bar {i} should be finite");
}
}
}
[Fact]
public void CcorIndicator_CustomPeriod_ReflectedInShortName()
{
var indicator = new CcorIndicator { Period = 30, Threshold = 5.0 };
Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("5.0", indicator.ShortName, StringComparison.Ordinal);
}
[Theory]
[InlineData(SourceType.Open)]
[InlineData(SourceType.High)]
[InlineData(SourceType.Low)]
[InlineData(SourceType.Close)]
public void CcorIndicator_DifferentSources_DoNotThrow(SourceType sourceType)
{
var indicator = new CcorIndicator
{
Period = 20,
Threshold = 9.0,
Source = sourceType
};
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class CcorIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 2, 200, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("Threshold", sortIndex: 2, 0.1, 90.0, 0.1, 1)]
public double Threshold { get; set; } = 9.0;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ccor _ccor = null!;
private readonly LineSeries _realSeries;
private readonly LineSeries _imagSeries;
private readonly LineSeries _angleSeries;
private readonly LineSeries _stateSeries;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"CCOR ({Period},{Threshold:F1})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/ccor/Ccor.Quantower.cs";
public CcorIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "CCOR - Ehlers Correlation Cycle";
Description = "Ehlers' Correlation Cycle uses dual Pearson correlation (cosine + negative sine) to derive a phasor, monotonic angle, and market state classification";
_realSeries = new LineSeries(name: "Real", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
_imagSeries = new LineSeries(name: "Imag", color: Color.FromArgb(128, 128, 255), width: 1, style: LineStyle.Dash);
_angleSeries = new LineSeries(name: "Angle", color: Color.FromArgb(200, 200, 100), width: 1, style: LineStyle.Dot);
_stateSeries = new LineSeries(name: "State", color: Color.FromArgb(255, 165, 0), width: 2, style: LineStyle.Histogramm);
AddLineSeries(_realSeries);
AddLineSeries(_imagSeries);
AddLineSeries(_angleSeries);
AddLineSeries(_stateSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_ccor = new Ccor(Period, Threshold);
_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 = _ccor.Update(input, args.IsNewBar());
_realSeries.SetValue(result.Value, _ccor.IsHot, ShowColdValues);
_imagSeries.SetValue(_ccor.Imag, _ccor.IsHot, ShowColdValues);
_angleSeries.SetValue(_ccor.Angle, _ccor.IsHot, ShowColdValues);
_stateSeries.SetValue(_ccor.MarketState, _ccor.IsHot, ShowColdValues);
}
}
+592
View File
@@ -0,0 +1,592 @@
using Xunit;
namespace QuanTAlib.Tests;
public class CcorTests
{
private static readonly GBM TestData = new(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
private static TSeries GetTestSeries(int count = 500)
{
return TestData.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
}
// ── A) Constructor validation ──
[Fact]
public void Ccor_DefaultConstructor_SetsDefaults()
{
var ind = new Ccor();
Assert.Equal("Ccor(20,9.0)", ind.Name);
Assert.Equal(20, ind.WarmupPeriod);
}
[Fact]
public void Ccor_CustomPeriod_SetsCorrectName()
{
var ind = new Ccor(period: 30, threshold: 5.0);
Assert.Equal("Ccor(30,5.0)", ind.Name);
Assert.Equal(30, ind.WarmupPeriod);
}
[Fact]
public void Ccor_ZeroPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Ccor_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: -5));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Ccor_ZeroThreshold_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: 20, threshold: 0.0));
Assert.Equal("threshold", ex.ParamName);
}
[Fact]
public void Ccor_NegativeThreshold_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: 20, threshold: -1.0));
Assert.Equal("threshold", ex.ParamName);
}
[Fact]
public void Ccor_ChainConstructor_NullSource_Throws()
{
Assert.Throws<ArgumentNullException>(() => new Ccor(null!, 20, 9.0));
}
// ── B) Basic calculation ──
[Fact]
public void Ccor_Update_ReturnsTValue()
{
var ind = new Ccor();
var result = ind.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.IsType<TValue>(result);
}
[Fact]
public void Ccor_AfterUpdate_LastIsAccessible()
{
var ind = new Ccor();
_ = ind.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(ind.Last.Value));
Assert.Equal("Ccor(20,9.0)", ind.Name);
}
[Fact]
public void Ccor_MultiOutput_AllAccessible()
{
var ind = new Ccor();
var series = GetTestSeries(50);
foreach (var tv in series)
{
_ = ind.Update(tv);
}
// All multi-output properties should be accessible and finite
Assert.True(double.IsFinite(ind.Real));
Assert.True(double.IsFinite(ind.Imag));
Assert.True(double.IsFinite(ind.Angle));
Assert.Contains(ind.MarketState, new[] { -1, 0, 1 });
}
[Fact]
public void Ccor_Real_BoundedMinusOneToOne()
{
var ind = new Ccor();
var series = GetTestSeries(200);
foreach (var tv in series)
{
_ = ind.Update(tv);
Assert.InRange(ind.Real, -1.0, 1.0);
}
}
[Fact]
public void Ccor_Imag_BoundedMinusOneToOne()
{
var ind = new Ccor();
var series = GetTestSeries(200);
foreach (var tv in series)
{
_ = ind.Update(tv);
Assert.InRange(ind.Imag, -1.0, 1.0);
}
}
// ── C) State + bar correction ──
[Fact]
public void Ccor_IsNew_True_AdvancesState()
{
var ind = new Ccor(period: 10);
var series = GetTestSeries(20);
foreach (var tv in series)
{
_ = ind.Update(tv, isNew: true);
}
Assert.True(ind.IsHot);
}
[Fact]
public void Ccor_IsNew_False_DoesNotAdvance()
{
var ind = new Ccor(period: 10);
var series = GetTestSeries(5);
// Process 5 bars normally
foreach (var tv in series)
{
_ = ind.Update(tv, isNew: true);
}
// Rewrite last bar with same value — should produce same result each time
_ = ind.Update(series[^1], isNew: false);
double realAfterFirst = ind.Real;
_ = ind.Update(series[^1], isNew: false);
double realAfterSecond = ind.Real;
Assert.Equal(realAfterFirst, realAfterSecond, 10);
}
[Fact]
public void Ccor_BarCorrection_IterativeUpdatesRestore()
{
var ind = new Ccor(period: 10);
var series = GetTestSeries(30);
// Process first 25 bars
for (int i = 0; i < 25; i++)
{
_ = ind.Update(series[i]);
}
double realSnapshot = ind.Real;
// Apply 5 corrections (isNew=false)
for (int i = 0; i < 5; i++)
{
_ = ind.Update(new TValue(series[24].Time, 100.0 + i), isNew: false);
}
// Reapply original — should restore
_ = ind.Update(series[24], isNew: false);
Assert.Equal(realSnapshot, ind.Real, 10);
}
[Fact]
public void Ccor_Reset_ClearsState()
{
var ind = new Ccor();
var series = GetTestSeries(50);
foreach (var tv in series)
{
_ = ind.Update(tv);
}
Assert.True(ind.IsHot);
ind.Reset();
Assert.False(ind.IsHot);
Assert.Equal(0.0, ind.Real);
Assert.Equal(0.0, ind.Imag);
Assert.Equal(0.0, ind.Angle);
Assert.Equal(0, ind.MarketState);
Assert.Equal(default, ind.Last);
}
// ── D) Warmup/convergence ──
[Fact]
public void Ccor_IsHot_FlipsAtWarmupPeriod()
{
int period = 15;
var ind = new Ccor(period: period);
var series = GetTestSeries(period + 5);
for (int i = 0; i < period - 1; i++)
{
_ = ind.Update(series[i]);
Assert.False(ind.IsHot, $"Should not be hot at bar {i + 1}");
}
_ = ind.Update(series[period - 1]);
Assert.True(ind.IsHot, $"Should be hot at bar {period}");
}
[Fact]
public void Ccor_WarmupPeriod_EqualsPeriod()
{
var ind = new Ccor(period: 30);
Assert.Equal(30, ind.WarmupPeriod);
}
// ── E) Robustness ──
[Fact]
public void Ccor_NaN_UsesLastValid()
{
var ind = new Ccor(period: 5);
var series = GetTestSeries(10);
for (int i = 0; i < 8; i++)
{
_ = ind.Update(series[i]);
}
_ = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(ind.Real));
}
[Fact]
public void Ccor_Infinity_UsesLastValid()
{
var ind = new Ccor(period: 5);
var series = GetTestSeries(10);
for (int i = 0; i < 8; i++)
{
_ = ind.Update(series[i]);
}
_ = ind.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(ind.Real));
Assert.True(double.IsFinite(ind.Imag));
}
[Fact]
public void Ccor_BatchNaN_AllFinite()
{
var series = GetTestSeries(50);
var ind = new Ccor(period: 10);
foreach (var tv in series)
{
_ = ind.Update(tv);
}
// Inject NaN batch
for (int i = 0; i < 5; i++)
{
_ = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
}
Assert.True(double.IsFinite(ind.Real));
Assert.True(double.IsFinite(ind.Imag));
Assert.True(double.IsFinite(ind.Angle));
}
[Fact]
public void Ccor_EmptyTSeries_ReturnsEmpty()
{
var ind = new Ccor();
var result = ind.Update(new TSeries());
Assert.Empty(result);
}
[Fact]
public void Ccor_LargeDataset_NoBlowup()
{
var largeData = TestData.Fetch(10000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
var ind = new Ccor();
for (int i = 0; i < largeData.Count; i++)
{
_ = ind.Update(largeData[i]);
Assert.True(double.IsFinite(ind.Real), $"Non-finite Real at index {i}");
Assert.True(double.IsFinite(ind.Imag), $"Non-finite Imag at index {i}");
}
}
// ── F) Consistency (4 API modes match) ──
[Fact]
public void Ccor_FourApiModes_Match()
{
var series = GetTestSeries(100);
int period = 20;
double threshold = 9.0;
// Mode 1: Streaming
var ind1 = new Ccor(period, threshold);
var streaming = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
streaming[i] = ind1.Update(series[i]).Value;
}
// Mode 2: Batch(TSeries)
var batchResult = Ccor.Batch(series, period, threshold);
// Mode 3: Batch(Span)
double[] srcVals = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
srcVals[i] = series[i].Value;
}
double[] spanResult = new double[series.Count];
Ccor.Batch(srcVals, spanResult, period, threshold);
// Mode 4: Eventing
var ind4 = new Ccor(period, threshold);
var eventResults = new List<double>();
ind4.Pub += (object? _, in TValueEventArgs e) => eventResults.Add(e.Value.Value);
foreach (var tv in series)
{
_ = ind4.Update(tv);
}
// Compare all modes
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(streaming[i], batchResult[i].Value, 10);
Assert.Equal(streaming[i], spanResult[i], 10);
Assert.Equal(streaming[i], eventResults[i], 10);
}
}
// ── G) Span API tests ──
[Fact]
public void Ccor_SpanBatch_MismatchedLengths_Throws()
{
double[] src = new double[10];
double[] output = new double[5];
var ex = Assert.Throws<ArgumentException>(() => Ccor.Batch(src, output));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Ccor_SpanBatch_ZeroPeriod_Throws()
{
double[] src = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ccor.Batch(src, output, period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Ccor_SpanBatch_ZeroThreshold_Throws()
{
double[] src = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Ccor.Batch(src, output, period: 20, threshold: 0.0));
Assert.Equal("threshold", ex.ParamName);
}
[Fact]
public void Ccor_SpanBatch_Empty_NoException()
{
double[] src = Array.Empty<double>();
double[] output = Array.Empty<double>();
Ccor.Batch(src, output); // should not throw
Assert.Empty(output);
}
[Fact]
public void Ccor_SpanBatch_MatchesTSeries()
{
var series = GetTestSeries(100);
int period = 15;
var batchResult = Ccor.Batch(series, period);
double[] srcVals = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
srcVals[i] = series[i].Value;
}
double[] spanResult = new double[series.Count];
Ccor.Batch(srcVals, spanResult, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(batchResult[i].Value, spanResult[i], 10);
}
}
[Fact]
public void Ccor_SpanBatch_NaN_Handled()
{
double[] src = { 100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109 };
double[] output = new double[10];
Ccor.Batch(src, output, period: 5);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Non-finite at index {i}");
}
}
// ── H) Chainability ──
[Fact]
public void Ccor_PubEvent_Fires()
{
var ind = new Ccor();
int count = 0;
ind.Pub += (object? _, in TValueEventArgs _) => count++;
var series = GetTestSeries(10);
foreach (var tv in series)
{
_ = ind.Update(tv);
}
Assert.Equal(10, count);
}
[Fact]
public void Ccor_EventChaining_Works()
{
var source = new Ccor(period: 10);
var chained = new Ccor(source, period: 5);
var series = GetTestSeries(50);
foreach (var tv in series)
{
_ = source.Update(tv);
}
Assert.True(chained.IsHot);
Assert.True(double.IsFinite(chained.Real));
}
// ── CCOR-specific tests ──
[Fact]
public void Ccor_ConstantInput_RealIsZero()
{
var ind = new Ccor(period: 10);
for (int i = 0; i < 30; i++)
{
_ = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
// Constant price → zero variance in x → correlation = 0
Assert.Equal(0.0, ind.Real, 10);
Assert.Equal(0.0, ind.Imag, 10);
}
[Fact]
public void Ccor_SineWave_DetectsCorrelation()
{
int period = 20;
var ind = new Ccor(period: period);
// Feed a perfect sine wave of the same period
for (int i = 0; i < 100; i++)
{
double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
_ = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val));
}
// After warmup, Real correlation with cosine should be significant (not zero)
double absReal = Math.Abs(ind.Real);
double absImag = Math.Abs(ind.Imag);
Assert.True(absReal > 0.1 || absImag > 0.1,
$"Sine wave should produce non-trivial correlation: Real={ind.Real:F4}, Imag={ind.Imag:F4}");
}
[Fact]
public void Ccor_AngleMonotonic_NeverDecreases()
{
var ind = new Ccor(period: 15);
var series = GetTestSeries(200);
double prevAngle = double.MinValue;
foreach (var tv in series)
{
_ = ind.Update(tv);
Assert.True(ind.Angle >= prevAngle,
$"Angle decreased: {ind.Angle:F4} < prev {prevAngle:F4}");
prevAngle = ind.Angle;
}
}
[Fact]
public void Ccor_MarketState_OnlyValidValues()
{
var ind = new Ccor();
var series = GetTestSeries(200);
foreach (var tv in series)
{
_ = ind.Update(tv);
Assert.Contains(ind.MarketState, new[] { -1, 0, 1 });
}
}
[Fact]
public void Ccor_DifferentPeriods_ProduceDifferentResults()
{
var series = GetTestSeries(100);
var ind10 = new Ccor(period: 10);
var ind30 = new Ccor(period: 30);
foreach (var tv in series)
{
_ = ind10.Update(tv);
_ = ind30.Update(tv);
}
// Different periods should produce different Real values
Assert.NotEqual(ind10.Real, ind30.Real, 5);
}
[Fact]
public void Ccor_Prime_SetsState()
{
var ind = new Ccor(period: 10);
var series = GetTestSeries(20);
double[] vals = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
vals[i] = series[i].Value;
}
ind.Prime(vals);
Assert.True(ind.IsHot);
Assert.True(double.IsFinite(ind.Real));
}
[Fact]
public void Ccor_Calculate_ReturnsBothResultsAndIndicator()
{
var series = GetTestSeries(50);
var (results, indicator) = Ccor.Calculate(series);
Assert.Equal(50, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Real));
Assert.True(double.IsFinite(indicator.Imag));
}
[Fact]
public void Ccor_Batch_TSeries_CorrectLength()
{
var series = GetTestSeries(100);
var result = Ccor.Batch(series);
Assert.Equal(100, result.Count);
}
[Fact]
public void Ccor_Update_TSeries_CorrectLength()
{
var ind = new Ccor();
var series = GetTestSeries(100);
var result = ind.Update(series);
Assert.Equal(100, result.Count);
}
}
+368
View File
@@ -0,0 +1,368 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for CCOR - Ehlers Correlation Cycle.
/// Since CCOR is a proprietary Ehlers algorithm with no standard library implementations
/// (not in TA-Lib, Skender, Tulip, or Ooples), these tests validate mathematical
/// properties of Pearson correlation and internal consistency across API modes.
/// </summary>
public class CcorValidationTests
{
private const double Tolerance = 1e-9;
private const long StartTime = 946_684_800_000_000_0L; // 2000-01-01 UTC in ticks
private static readonly TimeSpan Step = TimeSpan.FromMinutes(1);
#region Pearson Correlation Mathematical Properties
[Fact]
public void Ccor_ConstantInput_RealAndImagAreZero()
{
// Constant price → zero variance in x → correlation undefined → returns 0
var ccor = new Ccor(period: 10);
for (int i = 0; i < 100; i++)
{
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0), true);
}
Assert.Equal(0.0, ccor.Real, Tolerance);
Assert.Equal(0.0, ccor.Imag, Tolerance);
}
[Fact]
public void Ccor_PerfectCosineInput_RealNearOne()
{
// If price exactly matches the cosine reference, Real correlation → +1
int period = 20;
var ccor = new Ccor(period: period);
double twoPiOverN = 2.0 * Math.PI / period;
for (int i = 0; i < 200; i++)
{
double val = Math.Cos(twoPiOverN * (i % period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
// After many full cycles, Real should be very close to +1
Assert.True(ccor.Real > 0.95,
$"Perfect cosine input should give Real ≈ 1.0, got {ccor.Real:F6}");
}
[Fact]
public void Ccor_PerfectNegSineInput_ImagHighMagnitude()
{
// If price has -sin periodicity, Imag correlation magnitude should be near 1.0
int period = 20;
var ccor = new Ccor(period: period);
double twoPiOverN = 2.0 * Math.PI / period;
for (int i = 0; i < 200; i++)
{
double val = -Math.Sin(twoPiOverN * (i % period));
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
Assert.True(Math.Abs(ccor.Imag) > 0.90,
$"Perfect -sin input should give |Imag| ≈ 1.0, got {ccor.Imag:F6}");
}
[Fact]
public void Ccor_RealAndImag_BoundedMinusOneToOne()
{
// Pearson correlation coefficient is always in [-1, +1]
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(1000, StartTime, Step);
var ccor = new Ccor(period: 20);
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.InRange(ccor.Real, -1.0, 1.0);
Assert.InRange(ccor.Imag, -1.0, 1.0);
}
}
[Fact]
public void Ccor_SineWave_RealAndImagAreOrthogonal()
{
// For a pure sine wave at the indicator's period, the Real (cosine) and Imag (-sine)
// correlations should be approximately orthogonal components of a phasor
int period = 20;
var ccor = new Ccor(period: period);
for (int i = 0; i < 200; i++)
{
double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
// Both should be non-trivial
Assert.True(Math.Abs(ccor.Real) > 0.01 || Math.Abs(ccor.Imag) > 0.01,
$"Sine wave should produce non-trivial phasor: Real={ccor.Real:F4}, Imag={ccor.Imag:F4}");
// R² + I² should be near 1 for a pure tone at the matched frequency
double magnitude = Math.Sqrt(ccor.Real * ccor.Real + ccor.Imag * ccor.Imag);
Assert.True(magnitude > 0.5,
$"Phasor magnitude should be significant for matched sine: {magnitude:F4}");
}
#endregion
#region Angle Properties
[Fact]
public void Ccor_Angle_MonotonicallyNonDecreasing()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: 20);
double prevAngle = double.MinValue;
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.True(ccor.Angle >= prevAngle,
$"Angle decreased at bar {i}: {ccor.Angle:F4} < prev {prevAngle:F4}");
prevAngle = ccor.Angle;
}
}
[Fact]
public void Ccor_Angle_AdvancesOnCyclicInput()
{
// For cyclic input, the angle should advance significantly
int period = 20;
var ccor = new Ccor(period: period);
for (int i = 0; i < 200; i++)
{
double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
}
Assert.True(ccor.Angle > 0.0,
$"Angle should advance on cyclic input, got {ccor.Angle:F4}");
}
#endregion
#region Market State Properties
[Fact]
public void Ccor_MarketState_OnlyValidValues()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: 20, threshold: 9.0);
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
Assert.Contains(ccor.MarketState, new[] { -1, 0, 1 });
}
}
[Fact]
public void Ccor_MarketState_HasVariation()
{
// Over enough data, all three states should appear at least once
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(2000, StartTime, Step);
var ccor = new Ccor(period: 20, threshold: 9.0);
var states = new HashSet<int>();
for (int i = 0; i < bars.Count; i++)
{
ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
states.Add(ccor.MarketState);
}
Assert.True(states.Count >= 2,
$"Expected at least 2 distinct market states, got {states.Count}: {string.Join(",", states)}");
}
#endregion
#region Deterministic Reproducibility
[Theory]
[InlineData(42)]
[InlineData(123)]
[InlineData(456)]
public void Ccor_DeterministicOutput(int seed)
{
var gbm1 = new GBM(seed: seed);
var bars1 = gbm1.Fetch(200, StartTime, Step);
var gbm2 = new GBM(seed: seed);
var bars2 = gbm2.Fetch(200, StartTime, Step);
var ccor1 = new Ccor(period: 20, threshold: 9.0);
var ccor2 = new Ccor(period: 20, threshold: 9.0);
for (int i = 0; i < bars1.Count; i++)
{
var r1 = ccor1.Update(new TValue(bars1[i].Time, bars1[i].Close));
var r2 = ccor2.Update(new TValue(bars2[i].Time, bars2[i].Close));
Assert.Equal(r1.Value, r2.Value, Tolerance);
}
}
[Theory]
[InlineData(10)]
[InlineData(20)]
[InlineData(50)]
public void Ccor_AllPeriods_ProduceFiniteOutput(int period)
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccor = new Ccor(period: period);
for (int i = 0; i < bars.Count; i++)
{
var r = ccor.Update(new TValue(bars[i].Time, bars[i].Close));
Assert.True(double.IsFinite(r.Value), $"Non-finite at bar {i} with period={period}");
Assert.True(double.IsFinite(ccor.Real), $"Non-finite Real at bar {i}");
Assert.True(double.IsFinite(ccor.Imag), $"Non-finite Imag at bar {i}");
Assert.True(double.IsFinite(ccor.Angle), $"Non-finite Angle at bar {i}");
}
}
#endregion
#region Consistency Validation
[Fact]
public void Ccor_BatchMatchesStreaming_OnGBM()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var source = bars.Close;
// Streaming
var ccorStream = new Ccor(period: 20, threshold: 9.0);
var streamResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
var r = ccorStream.Update(source[i], true);
streamResults[i] = r.Value;
}
// Batch
var batchResults = Ccor.Batch(source, 20, 9.0);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchResults[i].Value, Tolerance);
}
}
[Fact]
public void Ccor_SpanMatchesBatch_OnGBM()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var source = bars.Close;
// TSeries batch
var batchResults = Ccor.Batch(source, 20, 9.0);
// Span batch
double[] values = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
values[i] = source[i].Value;
}
double[] output = new double[values.Length];
Ccor.Batch(values.AsSpan(), output.AsSpan(), 20, 9.0);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchResults[i].Value, output[i], Tolerance);
}
}
[Fact]
public void Ccor_ResetAndReprocess_Matches()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, StartTime, Step);
var source = bars.Close;
var ccor = new Ccor(period: 20, threshold: 9.0);
var results1 = ccor.Update(source);
ccor.Reset();
var results2 = ccor.Update(source);
Assert.Equal(results1.Count, results2.Count);
for (int i = 0; i < results1.Count; i++)
{
Assert.Equal(results1[i].Value, results2[i].Value, Tolerance);
}
}
#endregion
#region Period Sensitivity
[Fact]
public void Ccor_DifferentPeriods_ProduceDifferentResults()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, StartTime, Step);
var ccor10 = new Ccor(period: 10);
var ccor30 = new Ccor(period: 30);
double diffEnergy = 0;
for (int i = 0; i < bars.Count; i++)
{
var tv = new TValue(bars[i].Time, bars[i].Close);
var r10 = ccor10.Update(tv);
var r30 = ccor30.Update(tv);
if (i > 30)
{
double d = r10.Value - r30.Value;
diffEnergy += d * d;
}
}
Assert.True(diffEnergy > 1e-6,
$"Different periods should produce different outputs, diffEnergy={diffEnergy}");
}
[Fact]
public void Ccor_DifferentThresholds_ProduceDifferentMarketStates()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, StartTime, Step);
var ccorTight = new Ccor(period: 20, threshold: 1.0);
var ccorLoose = new Ccor(period: 20, threshold: 50.0);
int statesDiffer = 0;
for (int i = 0; i < bars.Count; i++)
{
var tv = new TValue(bars[i].Time, bars[i].Close);
ccorTight.Update(tv);
ccorLoose.Update(tv);
if (ccorTight.MarketState != ccorLoose.MarketState)
{
statesDiffer++;
}
}
// Real/Imag/Angle are independent of threshold — only MarketState differs
Assert.True(statesDiffer > 0,
"Different thresholds should produce different market state classifications");
}
#endregion
}
+435
View File
@@ -0,0 +1,435 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CCOR: Ehlers Correlation Cycle — extracts cycle phase by computing Pearson correlation
/// of a price window against cosine (Real) and negative-sine (Imaginary) reference waves,
/// converting the resulting phasor to an angle with monotonic constraint and classifying
/// the market state as trending or cycling.
/// </summary>
/// <remarks>
/// From John F. Ehlers, "Correlation As A Cycle Indicator" (Stocks &amp; Commodities, June 2020).
///
/// Algorithm:
/// 1. Dual Pearson correlation over sliding window of N bars:
/// Real = corr(price, cos(2πk/N)), Imag = corr(price, -sin(2πk/N))
/// 2. Phasor angle = 90° + atan(Real/Imag) with quadrant fix (if Imag &gt; 0: angle -= 180°)
/// 3. Monotonic constraint: angle = max(angle, prev_angle) — prevents backward spin
/// 4. State detection: |Δangle| &lt; threshold → trending (+1 uptrend / -1 downtrend), else cycling (0)
///
/// Properties:
/// - O(period) per bar for dual correlation loops
/// - Precomputed cos/sin tables eliminate per-bar trig calls
/// - Real, Imag bounded [-1, +1] by Pearson construction
/// - Zero allocation in hot path (RingBuffer is pre-allocated)
/// </remarks>
[SkipLocalsInit]
public sealed class Ccor : AbstractBase
{
private readonly int _period;
private readonly double _threshold;
private readonly double[] _cosTable;
private readonly double[] _negSinTable;
private readonly RingBuffer _buf;
[StructLayout(LayoutKind.Auto)]
private record struct State(double PrevAngle, int Count, double LastValid);
private State _s;
private State _ps;
/// <summary>Pearson correlation of price with cosine reference wave. Range [-1, +1].</summary>
public double Real { get; private set; }
/// <summary>Pearson correlation of price with negative-sine reference wave. Range [-1, +1].</summary>
public double Imag { get; private set; }
/// <summary>Phasor angle (degrees), monotonically increasing.</summary>
public double Angle { get; private set; }
/// <summary>Market state: +1 = uptrend, -1 = downtrend, 0 = cycling.</summary>
public int MarketState { get; private set; }
/// <inheritdoc />
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// Creates a new Ccor indicator.
/// </summary>
/// <param name="period">Presumed dominant cycle wavelength. Must be &gt; 0. Default 20.</param>
/// <param name="threshold">Angle rate threshold (degrees) for state detection. Must be &gt; 0. Default 9.0.</param>
public Ccor(int period = 20, double threshold = 9.0)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0.", nameof(period));
}
if (threshold <= 0.0)
{
throw new ArgumentException("Threshold must be greater than 0.", nameof(threshold));
}
_period = period;
_threshold = threshold;
// Precompute cos/sin lookup tables
_cosTable = new double[period];
_negSinTable = new double[period];
double twoPiOverN = 2.0 * Math.PI / period;
for (int k = 0; k < period; k++)
{
double angle = twoPiOverN * k;
_cosTable[k] = Math.Cos(angle);
_negSinTable[k] = -Math.Sin(angle);
}
_buf = new(period);
Name = $"Ccor({period},{threshold:F1})";
WarmupPeriod = period;
_s = default;
_ps = default;
}
/// <summary>
/// Creates a new Ccor indicator chained to a publisher source.
/// </summary>
public Ccor(ITValuePublisher source, int period = 20, double threshold = 9.0) : this(period, threshold)
{
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)
{
// State management: save/restore for bar correction
if (isNew)
{
_ps = _s;
_buf.Snapshot();
}
else
{
_s = _ps;
_buf.Restore();
}
var s = _s;
double price = input.Value;
// NaN/Infinity guard: substitute last valid value
if (!double.IsFinite(price))
{
price = s.LastValid;
}
else
{
s = s with { LastValid = price };
}
// Increment bar count
int count = isNew ? s.Count + 1 : s.Count;
// Add price to ring buffer
_buf.Add(price);
// Compute dual Pearson correlations
int n = Math.Min(count, _period);
double realVal = 0, imagVal = 0;
double angleVal = 0;
int stateVal = 0;
if (n >= 2)
{
realVal = ComputeCorrelation(_buf, _cosTable, n);
imagVal = ComputeCorrelation(_buf, _negSinTable, n);
// Phasor angle (degrees) with quadrant resolution
if (imagVal != 0.0)
{
angleVal = 90.0 + Math.Atan(realVal / imagVal) * (180.0 / Math.PI);
}
if (imagVal > 0.0)
{
angleVal -= 180.0;
}
// Monotonic constraint: angle cannot decrease
double savedPrev = s.PrevAngle;
if (angleVal < savedPrev)
{
angleVal = savedPrev;
}
// Market state detection
double angleChange = Math.Abs(angleVal - savedPrev);
if (angleChange < _threshold && angleVal >= 0.0)
{
stateVal = 1; // uptrend
}
else if (angleChange < _threshold && angleVal <= 0.0)
{
stateVal = -1; // downtrend
}
// else stateVal = 0 (cycling)
}
Real = realVal;
Imag = imagVal;
Angle = angleVal;
MarketState = stateVal;
_s = new State(angleVal, count, s.LastValid);
Last = new TValue(input.Time, realVal);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Processes a full TSeries, returning the Real correlation for each bar.
/// </summary>
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);
for (int i = 0; i < len; i++)
{
var result = Update(source[i]);
vSpan[i] = result.Value;
}
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <inheritdoc />
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.UtcNow, value));
}
}
/// <summary>
/// Static batch: creates a Ccor, processes source, returns output TSeries.
/// </summary>
public static TSeries Batch(TSeries source, int period = 20, double threshold = 9.0)
{
var ind = new Ccor(period, threshold);
return ind.Update(source);
}
/// <summary>
/// Static span-based batch: computes correlation cycle Real component into output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 20, double threshold = 9.0)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0.", nameof(period));
}
if (threshold <= 0.0)
{
throw new ArgumentException("Threshold must be greater than 0.", nameof(threshold));
}
int len = source.Length;
if (len == 0)
{
return;
}
// Precompute trig tables
const int StackallocThreshold = 256;
double[]? rentedCos = null;
scoped Span<double> cosTab;
if (period <= StackallocThreshold)
{
cosTab = stackalloc double[period];
}
else
{
rentedCos = ArrayPool<double>.Shared.Rent(period);
cosTab = rentedCos.AsSpan(0, period);
}
try
{
double twoPiOverN = 2.0 * Math.PI / period;
for (int k = 0; k < period; k++)
{
cosTab[k] = Math.Cos(twoPiOverN * k);
}
// Price ring buffer (manual circular)
double[]? rentedBuf = null;
scoped Span<double> priceBuf;
if (period <= StackallocThreshold)
{
priceBuf = stackalloc double[period];
}
else
{
rentedBuf = ArrayPool<double>.Shared.Rent(period);
priceBuf = rentedBuf.AsSpan(0, period);
}
try
{
priceBuf.Clear();
int bufIdx = 0;
int filled = 0;
double lastValid = 0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
priceBuf[bufIdx] = val;
bufIdx = (bufIdx + 1) % period;
if (filled < period)
{
filled++;
}
int n = filled;
double realVal = 0;
if (n >= 2)
{
// Compute Real correlation (cosine)
double sx = 0, sxx = 0, sxy = 0;
double sy = 0, syy = 0;
for (int k = 0; k < n; k++)
{
int idx = ((bufIdx - 1 - k) % period + period) % period;
double x = priceBuf[idx];
double y = cosTab[k];
sx += x;
sxx += x * x;
sxy += x * y;
sy += y;
syy += y * y;
}
double nd = n;
double dp = (nd * sxx - sx * sx) * (nd * syy - sy * sy);
realVal = dp > 0.0 ? Math.Clamp((nd * sxy - sx * sy) / Math.Sqrt(dp), -1.0, 1.0) : 0.0;
}
output[i] = realVal;
}
}
finally
{
if (rentedBuf != null)
{
ArrayPool<double>.Shared.Return(rentedBuf);
}
}
}
finally
{
if (rentedCos != null)
{
ArrayPool<double>.Shared.Return(rentedCos);
}
}
}
/// <summary>
/// Static convenience method: returns (TSeries results, Ccor indicator) for inspection.
/// </summary>
public static (TSeries Results, Ccor Indicator) Calculate(TSeries source, int period = 20, double threshold = 9.0)
{
var ind = new Ccor(period, threshold);
var results = ind.Update(source);
return (results, ind);
}
/// <inheritdoc />
public override void Reset()
{
_s = default;
_ps = default;
_buf.Clear();
Last = default;
Real = 0;
Imag = 0;
Angle = 0;
MarketState = 0;
}
/// <summary>
/// Computes Pearson correlation between the most recent n values in RingBuffer
/// and the first n entries of a reference wave table.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeCorrelation(RingBuffer buf, double[] refTable, int n)
{
double sx = 0, sxx = 0, sxy = 0;
double sy = 0, syy = 0;
int newest = buf.Count - 1;
for (int k = 0; k < n; k++)
{
double x = buf[newest - k];
double y = refTable[k];
sx += x;
sxx += x * x;
sxy += x * y;
sy += y;
syy += y * y;
}
double nd = n;
double denomProd = (nd * sxx - sx * sx) * (nd * syy - sy * sy);
if (denomProd <= 0.0)
{
return 0.0;
}
double r = (nd * sxy - sx * sy) / Math.Sqrt(denomProd);
return Math.Clamp(r, -1.0, 1.0);
}
}
+26
View File
@@ -129,6 +129,32 @@ function CCOR(source, period, threshold):
| `angle` | monotonically increasing degrees | Phasor angle of detected cycle |
| `state` | $\{-1, 0, +1\}$ | $-1$ = downtrend, $0$ = cycling, $+1$ = uptrend |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| ADD/SUB | 5×N | 1 | 5N |
| MUL | 5×N | 3 | 15N |
| DIV | 2 | 15 | 30 |
| SQRT | 1 | 15 | 15 |
| ATAN | 1 | 20 | 20 |
| CMP | 3 | 1 | 3 |
| CLAMP | 1 | 1 | 1 |
| **Total** | **~10N+8** | — | **~20N+69** |
For default period $N = 20$: ~269 cycles per bar. The O(N) cost comes from dual Pearson correlation loops over the sliding window. Precomputed cos/sin tables eliminate per-bar trig calls.
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Pearson correlation bounded [-1, +1] by construction |
| **Timeliness** | 8/10 | Full-window correlation; no recursive lag |
| **Smoothness** | 7/10 | Monotonic angle constraint prevents backward jumps |
| **Memory** | 8/10 | O(N) ring buffer + precomputed trig tables |
## Resources
- **Ehlers, J.F.** "Correlation As A Cycle Indicator." *Technical Analysis of Stocks & Commodities*, June 2020.