T3 Moving Average and associated tests

This commit is contained in:
Miha Kralj
2025-12-07 17:10:41 -08:00
parent 875998b288
commit 94d06b0749
8 changed files with 968 additions and 25 deletions
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Reflection;
using Tulip;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class DebugTulipTests
{
private readonly ITestOutputHelper _output;
public DebugTulipTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void ListTulipIndicators()
{
var type = typeof(Tulip.Indicators);
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Static);
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
_output.WriteLine("Tulip Indicators (Properties):");
foreach (var p in properties)
{
_output.WriteLine(p.Name);
}
_output.WriteLine("Tulip Indicators (Fields):");
foreach (var f in fields)
{
_output.WriteLine(f.Name);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class T3Indicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[InputParameter("Volume Factor", sortIndex: 2, 0, 1, 0.01, 2)]
public double VolumeFactor { get; set; } = 0.7;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private T3? ma;
protected LineSeries? Series;
protected string? SourceName;
private int _warmupBarIndex = -1;
public int MinHistoryDepths => Period * 6; // Approx warmup for 6 stages
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"T3({Period}, {VolumeFactor:F2}):{SourceName}";
public T3Indicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "T3 - Tillson T3 Moving Average";
Description = "Tillson T3 Moving Average";
Series = new(name: $"T3 {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new T3(Period, VolumeFactor);
SourceName = Source.ToString();
_warmupBarIndex = -1;
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
TValue input = this.GetInputValue(args, Source);
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TValue result = ma!.Update(input, isNew);
Series!.SetValue(result.Value);
Series!.SetMarker(0, Color.Transparent);
if (_warmupBarIndex < 0 && ma!.IsHot)
_warmupBarIndex = Count;
}
public override void OnPaintChart(PaintChartEventArgs args)
{
base.OnPaintChart(args);
int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count;
this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2);
}
}
+104
View File
@@ -0,0 +1,104 @@
namespace QuanTAlib.Tests;
public class T3Tests
{
[Fact]
public void T3_Constructor_Period_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new T3(0));
Assert.Throws<ArgumentException>(() => new T3(-1));
var t3 = new T3(10);
Assert.NotNull(t3);
}
[Fact]
public void T3_ConstantInput_ConvergesToInput()
{
var t3 = new T3(5, 0.7);
double input = 100.0;
// Feed enough values for T3 to converge (it has 6 cascaded EMAs)
for(int i = 0; i < 100; i++)
{
t3.Update(new TValue(DateTime.UtcNow, input));
}
Assert.Equal(input, t3.Last.Value, 1e-9);
}
[Fact]
public void T3_Parameters_AffectResult()
{
// Different volume factors should produce different results for changing data
var t3_low_v = new T3(10, 0.1);
var t3_high_v = new T3(10, 0.9);
var series = new TSeries();
series.Add(DateTime.UtcNow, 100);
series.Add(DateTime.UtcNow.AddMinutes(1), 110);
series.Add(DateTime.UtcNow.AddMinutes(2), 120);
t3_low_v.Update(series);
t3_high_v.Update(series);
Assert.NotEqual(t3_low_v.Last.Value, t3_high_v.Last.Value);
}
[Fact]
public void T3_Reset_ResetsState()
{
var t3 = new T3(10);
t3.Update(new TValue(DateTime.UtcNow, 100));
t3.Update(new TValue(DateTime.UtcNow, 110));
Assert.True(t3.IsHot);
Assert.NotEqual(0, t3.Last.Value);
t3.Reset();
Assert.False(t3.IsHot);
Assert.Equal(0, t3.Last.Value);
// Should accept new data as if fresh
t3.Update(new TValue(DateTime.UtcNow, 50));
Assert.Equal(50, t3.Last.Value, 1e-9); // First value logic: output = input
}
[Fact]
public void T3_Eventing_Works()
{
var source = new TSeries();
var t3 = new T3(source, 10);
double lastVal = 0;
t3.Pub += (v) => lastVal = v.Value;
source.Add(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100, lastVal, 1e-9);
source.Add(new TValue(DateTime.UtcNow, 110));
Assert.NotEqual(100, lastVal);
Assert.NotEqual(0, lastVal);
}
[Fact]
public void T3_SpanTests()
{
var series = new TSeries();
int count = 100;
for(int i=0; i<count; i++)
series.Add(DateTime.UtcNow.AddMinutes(i), 100 + i);
var t3 = new T3(10);
var resSeries = t3.Update(series);
var resSpan = new double[count];
// Correctly use Span.CopyTo
T3.Calculate(series, 10).Values.CopyTo(resSpan.AsSpan());
// Check last values match
Assert.Equal(resSeries.Last.Value, resSpan[count-1], 1e-9);
}
}
+234
View File
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Skender.Stock.Indicators;
using TALib;
using Tulip;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class T3ValidationTests
{
private readonly TBarSeries _bars;
private readonly TSeries _data;
private readonly List<Quote> _skenderQuotes;
private readonly ITestOutputHelper _output;
public T3ValidationTests(ITestOutputHelper output)
{
_output = output;
// 1. Generate 2000 records using GBM feed
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
_bars = gbm.Fetch(2000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// 2. Extract Close TSeries
_data = _bars.Close;
// 3. Prepare data for Skender (List<Quote>)
_skenderQuotes = new List<Quote>();
for (int i = 0; i < _bars.Count; i++)
{
_skenderQuotes.Add(new Quote
{
Date = new DateTime(_bars.Open.Times[i], DateTimeKind.Utc),
Open = (decimal)_bars.Open[i].Value,
High = (decimal)_bars.High[i].Value,
Low = (decimal)_bars.Low[i].Value,
Close = (decimal)_bars.Close[i].Value,
Volume = (decimal)_bars.Volume[i].Value
});
}
}
[Fact]
public void Validate_Skender_Batch()
{
int[] periods = { 5, 10, 20 };
double vFactor = 0.7;
foreach (var period in periods)
{
// Calculate QuanTAlib T3
var t3 = new global::QuanTAlib.T3(period, vFactor);
var qResult = t3.Update(_data);
// Calculate Skender T3
var sResult = _skenderQuotes.GetT3(period, vFactor).ToList();
// Compare last 100 records
VerifyData_Skender(qResult, sResult);
}
_output.WriteLine("T3 Batch(TSeries) validated successfully against Skender");
}
[Fact]
public void Validate_Talib_Batch()
{
int[] periods = { 5, 10, 20 };
double vFactor = 0.7;
// Prepare data for TA-Lib
double[] tData = _data.Select(x => x.Value).ToArray();
double[] output = new double[tData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib T3
var t3 = new global::QuanTAlib.T3(period, vFactor);
var qResult = t3.Update(_data);
// Calculate TA-Lib T3
var retCode = TALib.Functions.T3<double>(tData, 0..^0, output, out var outRange, period, vFactor);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.T3Lookback(period);
// Compare last 100 records
VerifyData_Talib(qResult, output, outRange, lookback);
}
_output.WriteLine("T3 Batch(TSeries) validated successfully against TA-Lib");
}
[Fact]
public void Validate_Talib_Streaming()
{
int[] periods = { 5, 10, 20 };
double vFactor = 0.7;
// Prepare data for TA-Lib
double[] tData = _data.Select(x => x.Value).ToArray();
double[] output = new double[tData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib T3 (streaming)
var t3 = new global::QuanTAlib.T3(period, vFactor);
var qResults = new List<double>();
foreach (var item in _data)
{
qResults.Add(t3.Update(item).Value);
}
// Calculate TA-Lib T3
var retCode = TALib.Functions.T3<double>(tData, 0..^0, output, out var outRange, period, vFactor);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.T3Lookback(period);
// Compare last 100 records
VerifyData_Talib_Streaming(qResults, output, outRange, lookback);
}
_output.WriteLine("T3 Streaming validated successfully against TA-Lib");
}
[Fact]
public void Validate_Talib_Span()
{
int[] periods = { 5, 10, 20 };
double vFactor = 0.7;
// Prepare data
double[] sourceData = _data.Select(x => x.Value).ToArray();
double[] talibOutput = new double[sourceData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib T3 (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.T3.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period, vFactor);
// Calculate TA-Lib T3
var retCode = TALib.Functions.T3<double>(sourceData, 0..^0, talibOutput, out var outRange, period, vFactor);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.T3Lookback(period);
// Compare last 100 records
VerifyData_Talib_Span(qOutput, talibOutput, outRange, lookback);
}
_output.WriteLine("T3 Span validated successfully against TA-Lib");
}
private static void VerifyData_Skender(TSeries qSeries, List<T3Result> sSeries)
{
Assert.Equal(qSeries.Count, sSeries.Count);
int count = qSeries.Count;
int skip = count - 100;
for (int i = skip; i < count; i++)
{
double qValue = qSeries[i].Value;
double? sValue = sSeries[i].T3;
if (!sValue.HasValue) continue;
Assert.Equal(sValue.Value, qValue, 1e-4);
}
}
private static void VerifyData_Talib(TSeries qSeries, double[] tOutput, Range outRange, int lookback)
{
int count = qSeries.Count;
int skip = count - 100;
int validCount = outRange.End.Value - outRange.Start.Value;
for (int i = skip; i < count; i++)
{
double qValue = qSeries[i].Value;
if (i < lookback) continue;
int tIndex = i - lookback;
if (tIndex >= validCount) continue;
double tValue = tOutput[tIndex];
Assert.Equal(tValue, qValue, 1e-4);
}
}
private static void VerifyData_Talib_Streaming(List<double> qResults, double[] tOutput, Range outRange, int lookback)
{
int count = qResults.Count;
int skip = count - 100;
int validCount = outRange.End.Value - outRange.Start.Value;
for (int i = skip; i < count; i++)
{
double qValue = qResults[i];
if (i < lookback) continue;
int tIndex = i - lookback;
if (tIndex >= validCount) continue;
double tValue = tOutput[tIndex];
Assert.Equal(tValue, qValue, 1e-4);
}
}
private static void VerifyData_Talib_Span(double[] qOutput, double[] tOutput, Range outRange, int lookback)
{
int count = qOutput.Length;
int skip = count - 100;
int validCount = outRange.End.Value - outRange.Start.Value;
for (int i = skip; i < count; i++)
{
double qValue = qOutput[i];
if (i < lookback) continue;
int tIndex = i - lookback;
if (tIndex >= validCount) continue;
double tValue = tOutput[tIndex];
Assert.Equal(tValue, qValue, 1e-4);
}
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// T3: Tillson T3 Moving Average
/// </summary>
/// <remarks>
/// T3 works by running price data through a series of six EMAs, then combining the outputs
/// of these EMAs using carefully calculated weights.
///
/// Formula:
/// T3 = c1*e6 + c2*e5 + c3*e4 + c4*e3
///
/// Where:
/// e1..e6 are cascaded EMAs
/// c1 = -v^3
/// c2 = 3(v^2 + v^3)
/// c3 = -3(2v^2 + v + v^3)
/// c4 = 1 + 3v + 3v^2 + v^3
///
/// v is volume factor (default 0.7)
/// alpha = 2 / (period + 1)
/// </remarks>
[SkipLocalsInit]
public sealed class T3 : ITValuePublisher
{
private struct State
{
public double E1, E2, E3, E4, E5, E6;
public bool IsInitialized;
public static State New() => new() { IsInitialized = false };
}
private readonly double _alpha;
private readonly double _c1, _c2, _c3, _c4;
private State _state = State.New();
private State _p_state = State.New();
private double _lastValidValue;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Creates T3 with specified period and volume factor.
/// </summary>
/// <param name="period">Period for EMA calculation (must be > 0)</param>
/// <param name="vfactor">Volume Factor (default 0.7)</param>
public T3(int period, double vfactor = 0.7)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_alpha = 2.0 / (period + 1);
// Precompute coefficients
double v = vfactor;
double v2 = v * v;
double v3 = v2 * v;
_c1 = -v3;
_c2 = 3.0 * (v2 + v3);
_c3 = -3.0 * (2.0 * v2 + v + v3);
_c4 = 1.0 + 3.0 * v + 3.0 * v2 + v3;
Name = $"T3({period}, {vfactor:F2})";
}
/// <summary>
/// Creates T3 with specified source, period and volume factor.
/// Subscribes to source.Pub event.
/// </summary>
/// <param name="source">Source to subscribe to</param>
/// <param name="period">Period for EMA calculation</param>
/// <param name="vfactor">Volume Factor (default 0.7)</param>
public T3(ITValuePublisher source, int period, double vfactor = 0.7) : this(period, vfactor)
{
source.Pub += (item) => Update(item);
}
/// <summary>
/// Current T3 value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the T3 has been initialized (received at least one value).
/// </summary>
public bool IsHot => _state.IsInitialized;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_lastValidValue = input;
return input;
}
return _lastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double val = GetValidValue(input.Value);
val = Compute(val, _alpha, _c1, _c2, _c3, _c4, ref _state);
Last = new TValue(input.Time, val);
Pub?.Invoke(Last);
return Last;
}
public TSeries Update(TSeries source)
{
if (source.Count == 0) return new TSeries(new List<long>(), new List<double>());
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);
var sourceValues = source.Values;
var sourceTimes = source.Times;
State state = _state;
double lastValidValue = _lastValidValue;
CalculateCore(sourceValues, vSpan, _alpha, _c1, _c2, _c3, _c4, ref state, ref lastValidValue);
_state = state;
_lastValidValue = lastValidValue;
sourceTimes.CopyTo(tSpan);
_p_state = _state;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double Compute(double input, double alpha, double c1, double c2, double c3, double c4, ref State state)
{
if (!state.IsInitialized)
{
state.E1 = state.E2 = state.E3 = state.E4 = state.E5 = state.E6 = input;
state.IsInitialized = true;
}
else
{
state.E1 += alpha * (input - state.E1);
state.E2 += alpha * (state.E1 - state.E2);
state.E3 += alpha * (state.E2 - state.E3);
state.E4 += alpha * (state.E3 - state.E4);
state.E5 += alpha * (state.E4 - state.E5);
state.E6 += alpha * (state.E5 - state.E6);
}
return c1 * state.E6 + c2 * state.E5 + c3 * state.E4 + c4 * state.E3;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output, double alpha,
double c1, double c2, double c3, double c4, ref State state, ref double lastValidValue)
{
int len = source.Length;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
lastValidValue = val;
else
val = lastValidValue;
output[i] = Compute(val, alpha, c1, c2, c3, c4, ref state);
}
}
/// <summary>
/// Calculates T3 for the entire series using a new instance.
/// </summary>
public static TSeries Calculate(TSeries source, int period, double vfactor = 0.7)
{
var t3 = new T3(period, vfactor);
return t3.Update(source);
}
/// <summary>
/// Calculates T3 in-place using period, writing results to pre-allocated output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period, double vfactor = 0.7)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
double alpha = 2.0 / (period + 1);
double v = vfactor;
double v2 = v * v;
double v3 = v2 * v;
double c1 = -v3;
double c2 = 3.0 * (v2 + v3);
double c3 = -3.0 * (2.0 * v2 + v + v3);
double c4 = 1.0 + 3.0 * v + 3.0 * v2 + v3;
State state = State.New();
double lastValidValue = 0;
CalculateCore(source, output, alpha, c1, c2, c3, c4, ref state, ref lastValidValue);
}
/// <summary>
/// Resets the T3 state.
/// </summary>
public void Reset()
{
_state = State.New();
_p_state = _state;
_lastValidValue = 0;
Last = default;
}
}
+66
View File
@@ -0,0 +1,66 @@
# T3: Tillson T3 Moving Average
## Overview and Purpose
The Tillson T3 Moving Average is an advanced technical indicator designed to provide superior smoothing with minimal lag. Developed by Tim Tillson and introduced in the January 1998 issue of Technical Analysis of Stocks & Commodities magazine, T3 implements a sophisticated six-stage EMA architecture with optimized coefficient distribution based on a volume factor parameter.
Unlike simpler moving averages or even triple-EMA approaches, T3 uses a unique mathematical framework that strategically combines multiple EMAs with precisely calculated coefficients. This approach creates a moving average that effectively reduces noise while preserving important trend information and minimizing lag.
## Core Concepts
* **Multi-stage smoothing:** Uses a six-stage EMA cascade with optimized coefficient distribution to achieve superior noise reduction while minimizing lag
* **Volume factor customization:** Provides a parameter that allows traders to fine-tune the balance between smoothness and responsiveness
* **Strategic coefficient weighting:** Employs a sophisticated formula that prevents overshooting at turning points while maintaining responsiveness
## Calculation and Mathematical Foundation
T3 works by running price data through a series of six EMAs, then combining the outputs of these EMAs using carefully calculated weights. These weights are determined by a "volume factor" parameter ($v$) that controls how much the indicator prioritizes smoothness versus responsiveness.
### Formula
$$ T3 = c_1 \cdot EMA_6 + c_2 \cdot EMA_5 + c_3 \cdot EMA_4 + c_4 \cdot EMA_3 $$
Where:
* $EMA_1$ through $EMA_6$ are exponential moving averages applied in sequence:
* $EMA_1(x) = EMA(x)$
* $EMA_n(x) = EMA(EMA_{n-1}(x))$
* Coefficients are derived from the volume factor $v$:
* $c_1 = -v^3$
* $c_2 = 3(v^2 + v^3)$
* $c_3 = -3(2v^2 + v + v^3)$
* $c_4 = 1 + 3v + 3v^2 + v^3$
* Default volume factor $v = 0.7$
## Parameters
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| Period | 10 | > 0 | The smoothing period for the internal EMAs |
| Volume Factor | 0.7 | 0-1 | Controls responsiveness vs smoothness (0.618 is also a common value) |
## C# Usage
### Standard TSeries Usage
```csharp
// Calculate T3 with period 10 and default volume factor 0.7
var t3 = T3.Calculate(sourceSeries, 10);
// Calculate T3 with period 10 and volume factor 0.618
var t3_custom = T3.Calculate(sourceSeries, 10, 0.618);
Console.WriteLine($"T3 Value: {t3.Last.Value}");
```
### Eventing and Reactive Support
The `T3` class implements `ITValuePublisher`, allowing for event-driven updates.
```csharp
// Create a publisher source
var source = new TValuePublisher();
// Create T3 consumer attached to the source
var t3 = new T3(source, period: 10, vfactor: 0.7);
// Handle updates
t3.Pub += (result) => {
Console.WriteLine($"New T3 Value: {result.Value} at {result.Time}");
};
// Push new values to source
source.Publish(new TValue(DateTime.UtcNow, 100.0));
```