Files
QuanTAlib/lib/oscillators/cti/Cti.cs
T
Miha Kralj 1910fdca93 chore: repo cleanup and code quality improvements
- Remove global.json (SDK pinning unnecessary)

- Remove nuget.config, move MyGet source to .csproj RestoreAdditionalProjectSources

- Gitignore ndepend/ entirely, move badges to docs/img/

- Update README.md and docs/ndepend.md badge paths

- Add NDepend project property to QuanTAlib.slnx

- Expand .editorconfig ReSharper/diagnostic suppressions

- Use ArgumentOutOfRangeException instead of ArgumentException

- Use discard _ for unused event sender parameters

- Remove quantalib.code-workspace and sonar-suppressions.json

- Add filter signature SVGs
2026-03-03 09:22:55 -08:00

331 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CTI: Correlation Trend Indicator (Ehlers, TASC 2020)
/// </summary>
/// <remarks>
/// Measures the Pearson correlation coefficient between the price series and a
/// perfect linear time index over a rolling window. Output is bounded [-1, +1]:
/// +1 = perfect uptrend, -1 = perfect downtrend, 0 = no linear trend.
///
/// Uses O(1) incremental running sums: ΣY, ΣY², ΣXY. The X-side sums (ΣX, ΣX²)
/// are analytical closed-form functions of n and never need maintenance.
///
/// Incremental ΣXY trick (same as CFO):
/// When the window slides forward one bar:
/// ΣXY -= ΣY_before_removal (shifts all position indices down by 1)
/// ΣXY += (n-1) × y_new (new value enters at highest position)
///
/// References:
/// Ehlers, J.F. (2001). Rocket Science for Traders. Wiley
/// PineScript reference: cti.pine
/// </remarks>
[SkipLocalsInit]
public sealed class Cti : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
// Precomputed X-side constants (full-window)
private readonly double _sx; // period*(period-1)/2
private readonly double _sxx; // period*(period-1)*(2*period-1)/6
private readonly double _denomX; // period*sxx - sx*sx (constant, never changes)
[StructLayout(LayoutKind.Auto)]
private record struct State(
double SumY,
double SumY2,
double SumXY,
int Count,
double LastValid);
private State _s, _ps;
private const int ResyncInterval = 1000;
private int _tickCount;
/// <summary>
/// Creates CTI with the specified lookback period.
/// </summary>
/// <param name="period">Rolling window length (must be ≥ 2)</param>
public Cti(int period = 20)
{
if (period < 2)
{
throw new ArgumentException("Period must be greater than or equal to 2", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Cti({period})";
WarmupPeriod = period;
_sx = period * (period - 1) / 2.0;
_sxx = period * (period - 1.0) * (2 * period - 1) / 6.0;
_denomX = Math.FusedMultiplyAdd(period, _sxx, -_sx * _sx);
}
/// <summary>
/// Creates CTI subscribed to an upstream publisher.
/// </summary>
public Cti(ITValuePublisher source, int period = 20) : this(period)
{
source.Pub += Handle;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
public override bool IsHot => _buffer.IsFull;
/// <summary>Period of the indicator.</summary>
public int Period => _period;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double value = input.Value;
// Sanitize input — substitute last-valid on NaN/Infinity
if (!double.IsFinite(value))
{
value = double.IsFinite(_s.LastValid) ? _s.LastValid : 0.0;
}
else
{
_s.LastValid = value;
}
if (isNew)
{
_ps = _s;
if (_buffer.Count == _buffer.Capacity)
{
// Full window: O(1) incremental update
double oldest = _buffer.Oldest;
_s.SumY -= oldest;
_s.SumY2 -= oldest * oldest;
_s.SumXY -= _s.SumY; // shift all indices down by 1
_s.SumXY += (_period - 1) * value; // new value at position (n-1)
}
else
{
// Growing window during warmup
_s.SumXY += _s.Count * value;
_s.Count++;
}
_s.SumY += value;
_s.SumY2 = Math.FusedMultiplyAdd(value, value, _s.SumY2);
_buffer.Add(value);
_tickCount++;
if (_buffer.IsFull && _tickCount >= ResyncInterval)
{
_tickCount = 0;
Resync();
}
}
else
{
_s = _ps;
_buffer.UpdateNewest(value);
Resync();
}
if (!_buffer.IsFull)
{
Last = new TValue(input.Time, 0.0);
PubEvent(Last, isNew);
return Last;
}
double cti = ComputePearson(_s.SumY, _s.SumY2, _s.SumXY, _period, _sx, _sxx, _denomX);
Last = new TValue(input.Time, cti);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
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);
// Replay to sync internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputePearson(
double sumY, double sumY2, double sumXY,
double n, double sx, double sxx, double denomX)
{
double denomY = Math.FusedMultiplyAdd(n, sumY2, -sumY * sumY);
double denom = denomX * denomY;
if (denom <= 0.0)
{
return 0.0;
}
double numer = Math.FusedMultiplyAdd(n, sumXY, -sx * sumY);
return Math.Clamp(numer / Math.Sqrt(denom), -1.0, 1.0);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Resync()
{
_s.SumY = 0.0;
_s.SumY2 = 0.0;
_s.SumXY = 0.0;
_s.Count = _buffer.Count;
for (int i = 0; i < _buffer.Count; i++)
{
double v = _buffer[i];
_s.SumY += v;
_s.SumY2 = Math.FusedMultiplyAdd(v, v, _s.SumY2);
_s.SumXY = Math.FusedMultiplyAdd(i, v, _s.SumXY);
}
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
public override void Reset()
{
_buffer.Clear();
_s = default;
_ps = default;
_tickCount = 0;
Last = default;
}
/// <summary>Calculates CTI for an entire TSeries.</summary>
public static TSeries Batch(TSeries source, int period = 20)
{
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);
return new TSeries(t, v);
}
/// <summary>
/// Batch CTI calculation using O(1) incremental Pearson correlation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 20)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period < 2)
{
throw new ArgumentException("Period must be greater than or equal to 2", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
double sx = period * (period - 1) / 2.0;
double sxx = period * (period - 1.0) * (2 * period - 1) / 6.0;
double denomX = Math.FusedMultiplyAdd(period, sxx, -sx * sx);
double sumY = 0.0;
double sumY2 = 0.0;
double sumXY = 0.0;
int count = 0;
double lastValid = 0.0;
var buf = new RingBuffer(period);
for (int i = 0; i < len; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
if (buf.Count == buf.Capacity)
{
double oldest = buf.Oldest;
sumY -= oldest;
sumY2 -= oldest * oldest;
sumXY -= sumY;
sumXY += (period - 1) * val;
}
else
{
sumXY += count * val;
count++;
}
sumY += val;
sumY2 = Math.FusedMultiplyAdd(val, val, sumY2);
buf.Add(val);
if (count < period)
{
output[i] = 0.0;
continue;
}
double denomY = Math.FusedMultiplyAdd(period, sumY2, -sumY * sumY);
double denom = denomX * denomY;
if (denom <= 0.0)
{
output[i] = 0.0;
continue;
}
double numer = Math.FusedMultiplyAdd(period, sumXY, -sx * sumY);
output[i] = Math.Clamp(numer / Math.Sqrt(denom), -1.0, 1.0);
}
}
/// <summary>Calculates CTI and returns both the series and the live indicator.</summary>
public static (TSeries Results, Cti Indicator) Calculate(TSeries source, int period = 20)
{
var indicator = new Cti(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}