From 94d06b0749258b3485aa80c539fba27557cc26d5 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sun, 7 Dec 2025 17:10:41 -0800 Subject: [PATCH] T3 Moving Average and associated tests --- lib/averages/hma/Hma.md | 70 ++++--- lib/averages/t3/DebugTulip.Tests.cs | 37 ++++ lib/averages/t3/T3.Quantower.cs | 67 +++++++ lib/averages/t3/T3.Tests.cs | 104 +++++++++++ lib/averages/t3/T3.Validation.Tests.cs | 234 ++++++++++++++++++++++++ lib/averages/t3/T3.cs | 242 +++++++++++++++++++++++++ lib/averages/t3/T3.md | 66 +++++++ quantower/T3Indicator.Tests.cs | 173 ++++++++++++++++++ 8 files changed, 968 insertions(+), 25 deletions(-) create mode 100644 lib/averages/t3/DebugTulip.Tests.cs create mode 100644 lib/averages/t3/T3.Quantower.cs create mode 100644 lib/averages/t3/T3.Tests.cs create mode 100644 lib/averages/t3/T3.Validation.Tests.cs create mode 100644 lib/averages/t3/T3.cs create mode 100644 lib/averages/t3/T3.md create mode 100644 quantower/T3Indicator.Tests.cs diff --git a/lib/averages/hma/Hma.md b/lib/averages/hma/Hma.md index b97b4f19..745fdfba 100644 --- a/lib/averages/hma/Hma.md +++ b/lib/averages/hma/Hma.md @@ -1,44 +1,64 @@ # HMA: Hull Moving Average -[Pine Script Implementation of HMA](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_FIR/hma.pine) - ## Overview and Purpose -The Hull Moving Average (HMA), developed by Alan Hull in 2005, is designed to solve the age-old problem of making a moving average more responsive to current price activity while maintaining curve smoothness. It achieves this by eliminating lag almost entirely and managing to improve smoothing at the same time. +The Hull Moving Average (HMA) is a technical indicator designed to significantly reduce lag while maintaining smoothness in price data interpretation. Developed by Australian mathematician and trader Alan Hull in 2005, the HMA was created specifically to address the lagging nature of conventional moving averages. Hull sought to create an indicator that maintained effective smoothing capabilities while improving responsiveness, publishing his approach in "Better Trading with the Hull Moving Average" (2005). Through its multi-stage calculation process involving weighted moving averages and square-root period weighting, HMA provides traders with a more responsive tool for identifying trends and potential reversals. ## Core Concepts -* **Lag Reduction:** Uses weighted moving averages (WMA) in a specific combination to offset lag. -* **Smoothness:** The final smoothing step ensures the indicator remains readable and not overly jittery. -* **Formula:** $HMA = WMA(\sqrt{n}, 2 \cdot WMA(n/2, price) - WMA(n, price))$ +* **Reduced lag:** HMA substantially decreases the delay in trend identification compared to traditional moving averages +* **Smoothing preservation:** Maintains effective noise filtering despite its increased responsiveness +* **Market application:** Particularly effective for timing entries and exits in trending markets where minimizing lag is critical +* **Timeframe flexibility:** Functions effectively across all timeframes with period adjustments to suit trading style -## Calculation +The core innovation of HMA is its unique three-stage process that includes weighted averaging at different timeframes, followed by a momentum-enhanced smoothing phase. By applying weight calculations at half the specified period, then taking the difference between this result and the full-period calculation, and finally smoothing that difference with a square-root weighted calculation, HMA creates a moving average that anticipates price movements rather than simply following them. -1. Calculate a WMA with period $n/2$ and multiply by 2. -2. Calculate a WMA with period $n$ and subtract from step 1. -3. Calculate a WMA with period $\sqrt{n}$ using the result of step 2. +## Common Settings and Parameters -## C# Implementation +| Parameter | Default | Function | When to Adjust | +|-----------|---------|----------|---------------| +| Length | 9 | Controls the primary calculation period | Increase for smoother signals in volatile markets, decrease for more responsiveness | +| Source | close | Price data used for calculation | Consider using hlc3 for a more balanced price representation | -```csharp -using QuanTAlib; +**Pro Tip:** Using square numbers (4, 9, 16, 25, 36) as periods can produce optimal results with HMA due to the square root operation in the final calculation step. -// Initialize -var hma = new Hma(14); +## Calculation and Mathematical Foundation -// Update -var result = hma.Update(new TValue(time, price)); +**Simplified explanation:** +HMA first calculates two weighted moving averages - one using half the specified period (which responds quickly) and one using the full period (which is smoother). It then doubles the faster WMA and subtracts the slower WMA to create a difference that emphasizes recent price direction. Finally, it applies another weighted moving average using the square root of the original period to smooth this difference. -// Batch -var series = Hma.Calculate(sourceSeries, 14); -``` +**Technical formula:** -## Performance +1. Calculate WMA with period n/2: WMA₁ = WMA(price, n/2) +2. Calculate WMA with period n: WMA₂ = WMA(price, n) +3. Calculate the difference: diff = 2 × WMA₁ - WMA₂ +4. Calculate final HMA: HMA = WMA(diff, √n) -* **Streaming:** O(1) complexity per update (uses 3 internal O(1) WMAs). -* **Batch:** Uses SIMD-optimized WMA calculations and vector operations for the intermediate step. -* **Zero Allocation:** Span-based API available for high-performance scenarios. +Where: + +* n is the specified period +* √n is the square root of n (rounded down) + +> 🔍 **Technical Note:** The 2× multiplier applied to the faster WMA serves to amplify the momentum component, helping the HMA anticipate rather than just follow price movements. + +## Interpretation Details + +HMA can be used in various trading strategies: + +* **Trend identification:** The direction of HMA indicates the prevailing trend +* **Signal generation:** Crossovers between price and HMA generate trade signals earlier than with traditional moving averages +* **Support/resistance levels:** HMA can act as dynamic support during uptrends and resistance during downtrends +* **Trend strength assessment:** The angle of the HMA line can indicate trend strength +* **Multiple timeframe analysis:** Using HMAs with different periods can confirm trends across different timeframes + +## Limitations and Considerations + +* **Market conditions:** Less effective in ranging or choppy markets where increased responsiveness may generate false signals +* **Overshooting:** The aggressive lag reduction can cause overshooting during sharp reversals +* **Amplitude distortion:** The 2× multiplier in the formula can exaggerate price movements +* **Gap sensitivity:** More prone to creating gaps in the moving average line during price gaps +* **Complementary tools:** Best used alongside momentum oscillators or volume indicators for confirmation ## References -* [Alan Hull's HMA Description](https://alan.hull.com.au/hma.html) +* Hull, Alan. "Better Trading with the Hull Moving Average." MTA Symposium Proceedings, 2005 diff --git a/lib/averages/t3/DebugTulip.Tests.cs b/lib/averages/t3/DebugTulip.Tests.cs new file mode 100644 index 00000000..38b2f9a5 --- /dev/null +++ b/lib/averages/t3/DebugTulip.Tests.cs @@ -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); + } + } +} diff --git a/lib/averages/t3/T3.Quantower.cs b/lib/averages/t3/T3.Quantower.cs new file mode 100644 index 00000000..63286c79 --- /dev/null +++ b/lib/averages/t3/T3.Quantower.cs @@ -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); + } +} diff --git a/lib/averages/t3/T3.Tests.cs b/lib/averages/t3/T3.Tests.cs new file mode 100644 index 00000000..0c480c37 --- /dev/null +++ b/lib/averages/t3/T3.Tests.cs @@ -0,0 +1,104 @@ + +namespace QuanTAlib.Tests; + +public class T3Tests +{ + [Fact] + public void T3_Constructor_Period_ValidatesInput() + { + Assert.Throws(() => new T3(0)); + Assert.Throws(() => 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 _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) + _skenderQuotes = new List(); + 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(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(); + foreach (var item in _data) + { + qResults.Add(t3.Update(item).Value); + } + + // Calculate TA-Lib T3 + var retCode = TALib.Functions.T3(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(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 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 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); + } + } +} diff --git a/lib/averages/t3/T3.cs b/lib/averages/t3/T3.cs new file mode 100644 index 00000000..5bd4eed8 --- /dev/null +++ b/lib/averages/t3/T3.cs @@ -0,0 +1,242 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// T3: Tillson T3 Moving Average +/// +/// +/// 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) +/// +[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; + + /// + /// Display name for the indicator. + /// + public string Name { get; } + + public event Action? Pub; + + /// + /// Creates T3 with specified period and volume factor. + /// + /// Period for EMA calculation (must be > 0) + /// Volume Factor (default 0.7) + 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})"; + } + + /// + /// Creates T3 with specified source, period and volume factor. + /// Subscribes to source.Pub event. + /// + /// Source to subscribe to + /// Period for EMA calculation + /// Volume Factor (default 0.7) + public T3(ITValuePublisher source, int period, double vfactor = 0.7) : this(period, vfactor) + { + source.Pub += (item) => Update(item); + } + + /// + /// Current T3 value. + /// + public TValue Last { get; private set; } + + /// + /// True if the T3 has been initialized (received at least one value). + /// + 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(), new List()); + + int len = source.Count; + var t = new List(len); + var v = new List(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 source, Span 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); + } + } + + /// + /// Calculates T3 for the entire series using a new instance. + /// + public static TSeries Calculate(TSeries source, int period, double vfactor = 0.7) + { + var t3 = new T3(period, vfactor); + return t3.Update(source); + } + + /// + /// Calculates T3 in-place using period, writing results to pre-allocated output span. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Calculate(ReadOnlySpan source, Span 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); + } + + /// + /// Resets the T3 state. + /// + public void Reset() + { + _state = State.New(); + _p_state = _state; + _lastValidValue = 0; + Last = default; + } +} diff --git a/lib/averages/t3/T3.md b/lib/averages/t3/T3.md new file mode 100644 index 00000000..540326cb --- /dev/null +++ b/lib/averages/t3/T3.md @@ -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)); +``` diff --git a/quantower/T3Indicator.Tests.cs b/quantower/T3Indicator.Tests.cs new file mode 100644 index 00000000..923c4334 --- /dev/null +++ b/quantower/T3Indicator.Tests.cs @@ -0,0 +1,173 @@ +using Xunit; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class T3IndicatorTests +{ + [Fact] + public void T3Indicator_Constructor_SetsDefaults() + { + var indicator = new T3Indicator(); + + Assert.Equal(10, indicator.Period); + Assert.Equal(0.7, indicator.VolumeFactor); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("T3 - Tillson T3 Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void T3Indicator_MinHistoryDepths_EqualsSixTimesPeriod() + { + var indicator = new T3Indicator { Period = 10 }; + + // MinHistoryDepths is Period * 6 for T3 due to 6 stages + Assert.Equal(60, indicator.MinHistoryDepths); + Assert.Equal(60, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void T3Indicator_ShortName_IncludesPeriodAndFactor() + { + var indicator = new T3Indicator { Period = 15, VolumeFactor = 0.618 }; + + Assert.Contains("T3", indicator.ShortName); + Assert.Contains("15", indicator.ShortName); + Assert.Contains("0.62", indicator.ShortName); // F2 formatting + } + + [Fact] + public void T3Indicator_Initialize_CreatesInternalT3() + { + var indicator = new T3Indicator { Period = 10 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void T3Indicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new T3Indicator { Period = 3 }; + 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 T3Indicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new T3Indicator { Period = 3 }; + indicator.Initialize(); + + // Add historical data + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + // Process first update + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + // Line series should have values + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void T3Indicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new T3Indicator { Period = 3 }; + indicator.Initialize(); + + // Add historical data + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + // Process historical bar first + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + // Update with new tick (same bar data - simulates intrabar update) + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + // Both values should be finite + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void T3Indicator_MultipleUpdates_ProducesCorrectT3Sequence() + { + var indicator = new T3Indicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105, 107, 106 }; + + 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))); + } + + double lastT3 = indicator.LinesSeries[0].GetValue(0); + Assert.True(lastT3 >= 100 && lastT3 <= 110); + } + + [Fact] + public void T3Indicator_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 T3Indicator { Period = 3, 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 T3Indicator_Parameters_CanBeChanged() + { + var indicator = new T3Indicator { Period = 5, VolumeFactor = 0.5 }; + Assert.Equal(5, indicator.Period); + Assert.Equal(0.5, indicator.VolumeFactor); + + indicator.Period = 20; + indicator.VolumeFactor = 0.9; + Assert.Equal(20, indicator.Period); + Assert.Equal(0.9, indicator.VolumeFactor); + Assert.Equal(120, indicator.MinHistoryDepths); // 20 * 6 + } +}