Add TEMA (Triple Exponential Moving Average) implementation and validation tests

- Implemented TEMA calculation in QuanTAlib with O(1) update complexity.
- Added validation tests for TEMA against Skender, TA-Lib, and Tulip indicators.
- Updated documentation for TEMA, including its mathematical foundation and usage examples.
- Enhanced existing tests for other indicators (TRIMA, WMA) to generate more records.
- Adjusted benchmark tests to include DEMA and TEMA comparisons.
- Refactored code for better readability and performance, including zero-allocation Span API.
This commit is contained in:
Miha Kralj
2025-12-04 19:57:46 -08:00
parent ee358bfdd9
commit 9e152b9027
24 changed files with 2528 additions and 136 deletions
+219
View File
@@ -0,0 +1,219 @@
#!meta
{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}}
#!markdown
# Triple Exponential Moving Average (TEMA) Examples
This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code.
For detailed documentation on the TEMA indicator, including mathematical formulas and interpretation, please refer to [Tema.md](Tema.md).
The **Triple Exponential Moving Average (TEMA)** is a technical indicator designed to reduce the lag associated with traditional moving averages even further than DEMA. It combines single, double, and triple EMAs to achieve superior responsiveness.
This notebook demonstrates:
1. **Manual Data Processing**: Understanding Batch vs. Streaming modes.
2. **Streaming with `isNew`**: Handling intra-bar updates.
3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data.
4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity.
#!csharp
// Reference the library
#r "..\..\bin\QuanTAlib.dll"
using System;
using System.Linq;
using QuanTAlib;
// Helper to print TSeries
void PrintSeries(TSeries series, int count = 5)
{
Console.WriteLine($"Series Length: {series.Count}");
foreach (var item in series.Take(count))
{
Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}");
}
if (series.Count > count) Console.WriteLine("...");
}
#!markdown
## 1. Manual Data: Batch vs. Streaming
We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work.
### Batch Processing
Batch processing calculates the TEMA for the entire dataset at once. This is efficient for historical analysis.
#!csharp
// Create a small manual dataset
var manualData = new TSeries();
manualData.Add(DateTime.Now, 100.0);
manualData.Add(DateTime.Now.AddMinutes(1), 102.0);
manualData.Add(DateTime.Now.AddMinutes(2), 101.0);
manualData.Add(DateTime.Now.AddMinutes(3), 103.0);
manualData.Add(DateTime.Now.AddMinutes(4), 105.0);
Console.WriteLine("--- Input Data ---");
PrintSeries(manualData, 5);
// Batch Calculation
Console.WriteLine("\n--- Batch TEMA (Period 3) ---");
var temaBatch = new Tema(3);
var resultBatch = temaBatch.Update(manualData);
PrintSeries(resultBatch, 5);
#!markdown
### Streaming Processing
Streaming processing updates the TEMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially.
#!csharp
Console.WriteLine("\n--- Streaming TEMA (Period 3) ---");
var temaStream = new Tema(3);
foreach (var item in manualData)
{
var result = temaStream.Update(item);
Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, TEMA: {result.Value:F2}, IsHot: {temaStream.IsHot}");
}
// Verify that the last values match
var batchLast = resultBatch.Last().Value;
var streamLast = temaStream.Value.Value;
Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})");
#!markdown
## 2. Streaming with `isNew` (Intra-bar Updates)
In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes.
* `isNew = true`: The input is a new bar (advances time).
* `isNew = false`: The input is an update to the current bar (recalculates without advancing).
#!csharp
Console.WriteLine("\n--- Streaming with Intra-bar Updates ---");
var temaIntra = new Tema(3);
// 1. Process the first 4 bars normally
for (int i = 0; i < 4; i++)
{
temaIntra.Update(manualData[i]);
}
Console.WriteLine($"After 4th bar: {temaIntra.Value.Value:F2}");
// 2. Simulate intra-bar updates for the 5th bar (Final value is 105.0)
// Update 1: Price moves to 104.0
var update1 = new TValue(manualData[4].Time, 104.0);
temaIntra.Update(update1, isNew: true); // First update for this bar is "New"
Console.WriteLine($"Update 1 (104.0): {temaIntra.Value.Value:F2}");
// Update 2: Price moves to 106.0 (Same time, same bar)
var update2 = new TValue(manualData[4].Time, 106.0);
temaIntra.Update(update2, isNew: false); // Not new, just an update
Console.WriteLine($"Update 2 (106.0): {temaIntra.Value.Value:F2}");
// Update 3: Final Close at 105.0
var update3 = manualData[4];
temaIntra.Update(update3, isNew: false); // Final update
Console.WriteLine($"Update 3 (105.0): {temaIntra.Value.Value:F2}");
// Verify match with batch result
Console.WriteLine($"Match with Batch: {Math.Abs(temaIntra.Value.Value - batchLast) < 1e-10}");
#!markdown
## 3. Large Dataset: Geometric Brownian Motion (GBM)
We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data.
#!csharp
// Generate 1000 bars of data
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1));
var closeSeries = gbmData.Close;
Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data.");
Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}");
#!markdown
### Batch vs. Streaming Performance on Large Data
#!csharp
// Batch
var temaLargeBatch = new Tema(20);
var batchLargeResult = temaLargeBatch.Update(closeSeries);
Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}");
// Streaming
var temaLargeStream = new Tema(20);
TValue lastStreamVal = default;
foreach(var item in closeSeries)
{
lastStreamVal = temaLargeStream.Update(item);
}
Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}");
#!markdown
## 4. Handling Invalid Values (NaN/Infinity)
`Tema` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation.
#!csharp
Console.WriteLine("\n--- Handling Invalid Values ---");
// Single TEMA
var temaNaN = new Tema(10);
// Feed valid values first
temaNaN.Update(new TValue(DateTime.Now, 100.0));
temaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0));
Console.WriteLine($"After valid values: {temaNaN.Value.Value:F2}");
// Feed NaN - should use last valid value (110)
var resultAfterNaN = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN));
Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F2} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})");
// Feed Infinity - should use last valid value (110)
var resultAfterInf = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity));
Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F2} (IsFinite: {double.IsFinite(resultAfterInf.Value)})");
// Continue with valid value
var resultAfterValid = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0));
Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F2}");
#!csharp
Console.WriteLine("\n--- Batch Processing with Invalid Values ---");
// Create series with NaN values interspersed
var seriesWithNaN = new TSeries();
seriesWithNaN.Add(DateTime.Now.Ticks, 100.0);
seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0);
seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN);
seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0);
seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity);
seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0);
var temaBatchNaN = new Tema(3);
var resultsWithNaN = temaBatchNaN.Update(seriesWithNaN);
Console.WriteLine("Input → Output:");
for (int i = 0; i < seriesWithNaN.Count; i++)
{
var input = seriesWithNaN[i].Value;
var output = resultsWithNaN[i].Value;
var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString();
Console.WriteLine($" {inputStr,-10} → {output:F2} (IsFinite: {double.IsFinite(output)})");
}
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class TemaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Tema? ma;
protected LineSeries? Series;
protected string? SourceName;
private int _warmupBarIndex = -1;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"TEMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/tema/Tema.Quantower.cs";
public TemaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "TEMA - Triple Exponential Moving Average";
Description = "Triple Exponential Moving Average";
Series = new(name: $"TEMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Tema(Period);
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);
}
}
+266
View File
@@ -0,0 +1,266 @@
namespace QuanTAlib.Tests;
public class TemaTests
{
[Fact]
public void Tema_Constructor_Period_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Tema(0));
Assert.Throws<ArgumentException>(() => new Tema(-1));
var tema = new Tema(10);
Assert.NotNull(tema);
}
[Fact]
public void Tema_Constructor_Alpha_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Tema(0.0));
Assert.Throws<ArgumentException>(() => new Tema(-0.1));
Assert.Throws<ArgumentException>(() => new Tema(1.1));
var tema = new Tema(0.5);
Assert.NotNull(tema);
}
[Fact]
public void Tema_Calc_ReturnsValue()
{
var tema = new Tema(10);
Assert.Equal(0, tema.Value.Value);
TValue result = tema.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0);
Assert.Equal(result.Value, tema.Value.Value);
}
[Fact]
public void Tema_Calc_IsNew_AcceptsParameter()
{
var tema = new Tema(10);
tema.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = tema.Value;
tema.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
double value2 = tema.Value;
// Values should change with new bars
Assert.NotEqual(value1, value2);
}
[Fact]
public void Tema_Calc_IsNew_False_UpdatesValue()
{
var tema = new Tema(10);
tema.Update(new TValue(DateTime.UtcNow, 100));
tema.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = tema.Value;
tema.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = tema.Value;
// Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void Tema_Reset_ClearsState()
{
var tema = new Tema(10);
tema.Update(new TValue(DateTime.UtcNow, 100));
tema.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = tema.Value;
tema.Reset();
Assert.Equal(0, tema.Value.Value);
// After reset, should accept new values
tema.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, tema.Value.Value);
Assert.NotEqual(valueBefore, tema.Value.Value);
}
[Fact]
public void Tema_Properties_Accessible()
{
var tema = new Tema(10);
Assert.Equal(0, tema.Value.Value);
Assert.False(tema.IsHot);
tema.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, tema.Value.Value);
}
[Fact]
public void Tema_IsHot_BecomesTrueAfterWarmup()
{
var tema = new Tema(10);
// Initially IsHot should be false
Assert.False(tema.IsHot);
// TEMA needs more warmup than EMA due to triple smoothing
int steps = 0;
while (!tema.IsHot && steps < 1000)
{
tema.Update(new TValue(DateTime.UtcNow, 100));
steps++;
}
Assert.True(tema.IsHot);
Assert.True(steps > 0);
}
[Fact]
public void Tema_PeriodEquivalence_BothConstructorsWork()
{
int period = 20;
double alpha = 2.0 / (period + 1);
var temaPeriod = new Tema(period);
var temaAlpha = new Tema(alpha);
// Both should accept Calc calls and produce same result
TValue result1 = temaPeriod.Update(new TValue(DateTime.UtcNow, 100));
TValue result2 = temaAlpha.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result1.Value, result2.Value, 1e-10);
}
[Fact]
public void Tema_IterativeCorrections_RestoreToOriginalState()
{
var tema = new Tema(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 10 new values
TValue tenthInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
tenthInput = new TValue(bar.Time, bar.Close);
tema.Update(tenthInput, isNew: true);
}
// Remember TEMA state after 10 values
double temaAfterTen = tema.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
tema.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered 10th input again with isNew=false
TValue finalTema = tema.Update(tenthInput, isNew: false);
// TEMA should match the original state after 10 values
Assert.Equal(temaAfterTen, finalTema.Value, 1e-10);
}
[Fact]
public void Tema_BatchCalc_MatchesIterativeCalc()
{
var temaIterative = new Tema(10);
var temaBatch = new Tema(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Generate data
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
Assert.True(series.Count > 0);
// Calculate iteratively
var iterativeResults = new TSeries();
foreach (var item in series)
{
iterativeResults.Add(temaIterative.Update(item));
}
// Calculate batch
var batchResults = temaBatch.Update(series);
// Compare
Assert.Equal(iterativeResults.Count, batchResults.Count);
for (int i = 0; i < iterativeResults.Count; i++)
{
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
Assert.Equal(iterativeResults[i].Time, batchResults[i].Time);
}
}
[Fact]
public void Tema_NaN_Input_UsesLastValidValue()
{
var tema = new Tema(10);
// Feed some valid values
tema.Update(new TValue(DateTime.UtcNow, 100));
tema.Update(new TValue(DateTime.UtcNow, 110));
// Feed NaN - should use last valid value (110)
var resultAfterNaN = tema.Update(new TValue(DateTime.UtcNow, double.NaN));
// Result should be finite (not NaN)
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Tema_SpanCalc_MatchesTSeriesCalc()
{
var series = new TSeries();
double[] source = new double[100];
double[] output = new double[100];
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
source[i] = bar.Close;
series.Add(bar.Time, bar.Close);
}
// Calculate with TSeries API
var tseriesResult = Tema.Calculate(series, 10);
// Calculate with Span API
Tema.Calculate(source.AsSpan(), output.AsSpan(), 10);
// Compare results
for (int i = 0; i < 100; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-9);
}
}
[Fact]
public void Tema_SpanCalc_ZeroAllocation()
{
double[] source = new double[10000];
double[] output = new double[10000];
var rng = new Random(42);
for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100;
// Warm up
Tema.Calculate(source.AsSpan(), output.AsSpan(), 100);
// This test verifies the method runs without throwing
Assert.True(double.IsFinite(output[^1]));
}
}
+233
View File
@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Skender.Stock.Indicators;
using TALib;
using Tulip;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class TemaValidationTests
{
private readonly TBarSeries _bars;
private readonly TSeries _data;
private readonly List<Quote> _skenderQuotes;
private readonly ITestOutputHelper _output;
public TemaValidationTests(ITestOutputHelper output)
{
_output = output;
// 1. Generate 5000 records using GBM feed
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
_bars = gbm.Fetch(5000, 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, 50, 100 };
foreach (var period in periods)
{
// Calculate QuanTAlib TEMA (batch TSeries)
var tema = new global::QuanTAlib.Tema(period);
var qResult = tema.Update(_data);
// Calculate Skender TEMA
var sResult = _skenderQuotes.GetTema(period).ToList();
// Compare last 100 records
VerifyData_Skender(qResult, sResult);
}
_output.WriteLine("TEMA Batch(TSeries) validated successfully against Skender.Stock.Indicators");
}
[Fact]
public void Validate_Talib_Batch()
{
int[] periods = { 5, 10, 20, 50, 100 };
// Prepare data for TA-Lib (double[])
double[] tData = _data.Select(x => x.Value).ToArray();
double[] output = new double[tData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib TEMA (batch TSeries)
var tema = new global::QuanTAlib.Tema(period);
var qResult = tema.Update(_data);
// Calculate TA-Lib TEMA
var retCode = TALib.Functions.Tema<double>(tData, 0..^0, output, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.TemaLookback(period);
// Compare last 100 records
VerifyData_Talib(qResult, output, outRange, lookback);
}
_output.WriteLine("TEMA Batch(TSeries) validated successfully against TA-Lib");
}
[Fact]
public void Validate_Tulip_Batch()
{
int[] periods = { 5, 10, 20, 50, 100 };
// Prepare data for Tulip (double[])
double[] tData = _data.Select(x => x.Value).ToArray();
foreach (var period in periods)
{
// Calculate QuanTAlib TEMA (batch TSeries)
var tema = new global::QuanTAlib.Tema(period);
var qResult = tema.Update(_data);
// Calculate Tulip TEMA
var temaIndicator = Tulip.Indicators.tema;
double[][] inputs = { tData };
double[] options = { period };
// Tulip TEMA lookback is 3*(period-1)
int lookback = 3 * (period - 1);
double[][] outputs = { new double[tData.Length - lookback] };
temaIndicator.Run(inputs, options, outputs);
var tResult = outputs[0];
// Compare last 100 records
VerifyData_Tulip(qResult, tResult, lookback);
}
_output.WriteLine("TEMA Batch(TSeries) validated successfully against Tulip");
}
[Fact]
public void Validate_Talib_Span()
{
int[] periods = { 5, 10, 20, 50, 100 };
// Prepare data
double[] sourceData = _data.Select(x => x.Value).ToArray();
double[] talibOutput = new double[sourceData.Length];
foreach (var period in periods)
{
// Calculate QuanTAlib TEMA (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.Tema.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
// Calculate TA-Lib TEMA
var retCode = TALib.Functions.Tema<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.TemaLookback(period);
// Compare last 100 records
VerifyData_Talib_Span(qOutput, talibOutput, outRange, lookback);
}
_output.WriteLine("TEMA Span validated successfully against TA-Lib");
}
// ==================== Verification Helpers ====================
private static void VerifyData_Skender(TSeries qSeries, List<TemaResult> 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].Tema;
if (!sValue.HasValue) continue;
Assert.Equal(sValue.Value, qValue, 1e-6);
}
}
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-5);
}
}
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-5);
}
}
private static void VerifyData_Tulip(TSeries qSeries, double[] tOutput, int lookback)
{
int count = qSeries.Count;
int skip = count - 100;
for (int i = skip; i < count; i++)
{
double qValue = qSeries[i].Value;
if (i < lookback) continue;
int tIndex = i - lookback;
if (tIndex >= tOutput.Length) continue;
double tValue = tOutput[tIndex];
Assert.Equal(tValue, qValue, 1e-5);
}
}
}
+325
View File
@@ -0,0 +1,325 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// TEMA: Triple Exponential Moving Average
/// </summary>
/// <remarks>
/// TEMA uses triple smoothing to reduce lag even further than DEMA.
///
/// Calculation:
/// EMA1 = EMA(input)
/// EMA2 = EMA(EMA1)
/// EMA3 = EMA(EMA2)
/// TEMA = 3 * EMA1 - 3 * EMA2 + EMA3
///
/// O(1) update:
/// Uses three EMA instances, each with O(1) update complexity.
///
/// IsHot:
/// Becomes true when the third EMA converges (approx. 3x EMA convergence time).
/// </remarks>
[SkipLocalsInit]
public sealed class Tema
{
private struct EmaState
{
public double Ema;
public double E;
public bool IsHot;
public bool IsCompensated;
public static EmaState New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false };
}
private readonly double _alpha;
private readonly double _decay;
private EmaState _state1 = EmaState.New();
private EmaState _state2 = EmaState.New();
private EmaState _state3 = EmaState.New();
private EmaState _p_state1 = EmaState.New();
private EmaState _p_state2 = EmaState.New();
private EmaState _p_state3 = EmaState.New();
private double _lastValidValue;
public string Name { get; }
public TValue Value { get; private set; }
public bool IsHot => _state3.IsHot;
public Tema(int period)
{
if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period));
_alpha = 2.0 / (period + 1);
_decay = 1.0 - _alpha;
Name = $"Tema({period})";
}
public Tema(double alpha)
{
if (alpha <= 0 || alpha > 1) throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
_alpha = alpha;
_decay = 1.0 - alpha;
Name = $"Tema(α={alpha:F4})";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state1 = _state1;
_p_state2 = _state2;
_p_state3 = _state3;
}
else
{
_state1 = _p_state1;
_state2 = _p_state2;
_state3 = _p_state3;
}
// EMA1
double val = input.Value;
if (double.IsFinite(val))
_lastValidValue = val;
else
val = _lastValidValue;
double e1 = Compute(val, _alpha, _decay, ref _state1);
// EMA2 (input is e1)
double e2 = Compute(e1, _alpha, _decay, ref _state2);
// EMA3 (input is e2)
double e3 = Compute(e2, _alpha, _decay, ref _state3);
double result = 3 * e1 - 3 * e2 + e3;
Value = new TValue(input.Time, result);
return Value;
}
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);
source.Times.CopyTo(tSpan);
var sourceValues = source.Values;
// Use current state
EmaState s1 = _state1;
EmaState s2 = _state2;
EmaState s3 = _state3;
double lastValid = _lastValidValue;
double alpha = _alpha;
double decay = _decay;
for (int i = 0; i < len; i++)
{
double val = sourceValues[i];
if (double.IsFinite(val))
lastValid = val;
else
val = lastValid;
double e1 = Compute(val, alpha, decay, ref s1);
double e2 = Compute(e1, alpha, decay, ref s2);
double e3 = Compute(e2, alpha, decay, ref s3);
vSpan[i] = 3 * e1 - 3 * e2 + e3;
}
// Update instance state
_state1 = s1;
_state2 = s2;
_state3 = s3;
_p_state1 = s1;
_p_state2 = s2;
_p_state3 = s3;
_lastValidValue = lastValid;
Value = 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 decay, ref EmaState state)
{
state.Ema += alpha * (input - state.Ema);
double result;
if (!state.IsCompensated)
{
state.E *= decay;
if (!state.IsHot && state.E <= 0.05) // COVERAGE_THRESHOLD
state.IsHot = true;
if (state.E <= 1e-10) // COMPENSATOR_THRESHOLD
{
state.IsCompensated = true;
result = state.Ema;
}
else
{
result = state.Ema / (1.0 - state.E);
}
}
else
{
result = state.Ema;
}
return result;
}
public static TSeries Calculate(TSeries source, int period)
{
var tema = new Tema(period);
return tema.Update(source);
}
public static TSeries Calculate(TSeries source, double alpha)
{
var tema = new Tema(alpha);
return tema.Update(source);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
double alpha = 2.0 / (period + 1);
Calculate(source, output, alpha);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, double alpha)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
if (alpha <= 0 || alpha > 1)
throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
if (source.Length == 0) return;
double decay = 1.0 - alpha;
double lastValid = 0;
// State for EMA1
double ema1_val = 0;
double ema1_e = 1.0;
bool ema1_isCompensated = false;
// State for EMA2
double ema2_val = 0;
double ema2_e = 1.0;
bool ema2_isCompensated = false;
// State for EMA3
double ema3_val = 0;
double ema3_e = 1.0;
bool ema3_isCompensated = false;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (double.IsFinite(val))
lastValid = val;
else
val = lastValid;
// Update EMA1
ema1_val += alpha * (val - ema1_val);
double e1;
if (!ema1_isCompensated)
{
ema1_e *= decay;
if (ema1_e <= 1e-10)
{
ema1_isCompensated = true;
e1 = ema1_val;
}
else
{
e1 = ema1_val / (1.0 - ema1_e);
}
}
else
{
e1 = ema1_val;
}
// Update EMA2 (input is e1)
ema2_val += alpha * (e1 - ema2_val);
double e2;
if (!ema2_isCompensated)
{
ema2_e *= decay;
if (ema2_e <= 1e-10)
{
ema2_isCompensated = true;
e2 = ema2_val;
}
else
{
e2 = ema2_val / (1.0 - ema2_e);
}
}
else
{
e2 = ema2_val;
}
// Update EMA3 (input is e2)
ema3_val += alpha * (e2 - ema3_val);
double e3;
if (!ema3_isCompensated)
{
ema3_e *= decay;
if (ema3_e <= 1e-10)
{
ema3_isCompensated = true;
e3 = ema3_val;
}
else
{
e3 = ema3_val / (1.0 - ema3_e);
}
}
else
{
e3 = ema3_val;
}
// TEMA = 3 * EMA1 - 3 * EMA2 + EMA3
output[i] = 3 * e1 - 3 * e2 + e3;
}
}
public void Reset()
{
_state1 = EmaState.New();
_state2 = EmaState.New();
_state3 = EmaState.New();
_p_state1 = EmaState.New();
_p_state2 = EmaState.New();
_p_state3 = EmaState.New();
_lastValidValue = 0;
Value = default;
}
}
+105
View File
@@ -0,0 +1,105 @@
# TEMA: Triple Exponential Moving Average
## Overview and Purpose
The Triple Exponential Moving Average (TEMA) is a technical indicator developed by Patrick Mulloy in 1994, introduced alongside DEMA. It takes the concept of lag reduction even further than DEMA by using a triple smoothing technique. TEMA is designed to be even more responsive to price changes than DEMA or traditional moving averages, effectively eliminating the lag associated with trend-following indicators.
TEMA is constructed using a combination of single, double, and triple Exponential Moving Averages (EMAs). This unique composition allows it to track price action very closely, making it a favorite among short-term traders and scalpers who require immediate signals.
## Core Concepts
* **Maximum Lag Reduction:** TEMA offers superior lag reduction compared to SMA, EMA, and even DEMA.
* **Triple Smoothing:** It utilizes three layers of EMA calculations to derive its value.
* **Composite Formula:** The formula cleverly combines $EMA_1$, $EMA_2$, and $EMA_3$ to subtract lag.
* **Trend Following:** Despite its speed, it remains a trend-following indicator, useful for identifying direction and reversals.
## Common Settings and Parameters
| Parameter | Default | Function | When to Adjust |
|-----------|---------|----------|---------------|
| Length | 20 | Controls responsiveness/smoothness | Shorter for scalping, longer for trend filtering |
| Source | Close | Data point used for calculation | Change to HL2 or HLC3 for typical price representation |
| Alpha | 3/(length+1) | Determines weighting decay | Direct alpha manipulation allows for precise tuning |
## Calculation and Mathematical Foundation
**Simplified explanation:**
TEMA uses a single EMA, a double EMA (EMA of EMA), and a triple EMA (EMA of EMA of EMA). It combines these three components to cancel out the lag inherent in the smoothing process.
**Technical formula:**
$$TEMA = 3 \times EMA_1 - 3 \times EMA_2 + EMA_3$$
Where:
* $EMA_1 = EMA(Price)$
* $EMA_2 = EMA(EMA_1)$
* $EMA_3 = EMA(EMA_2)$
The formula is derived from the error correction principle, similar to DEMA but extended to a third degree.
The lag error is estimated and subtracted from the original EMA, resulting in a highly responsive curve that often leads price turns.
> 🔍 **Technical Note:** The implementation leverages the optimized `Ema` class, which uses **Hunter's bias compensation**. This ensures that all three underlying EMAs are initialized correctly from the very first data point, providing accurate TEMA values immediately without a long warmup period.
## C# Implementation
The library provides a high-performance implementation of TEMA that supports both standard period-based initialization and direct alpha specification.
### Usage Examples
```csharp
using QuanTAlib;
// Initialize with period 14
var tema = new Tema(14);
// Or initialize with specific alpha
var temaAlpha = new Tema(0.15);
// Streaming update
TValue result = tema.Update(new TValue(time, price));
Console.WriteLine($"Current TEMA: {result.Value}");
// Batch calculation (TSeries API)
TSeries source = ...;
TSeries results = Tema.Calculate(source, 14);
// High-performance Span API (zero allocation)
double[] prices = new double[10000];
double[] output = new double[10000];
Tema.Calculate(prices.AsSpan(), output.AsSpan(), period: 14);
```
### Zero-Allocation Span API
For performance-critical scenarios, the static `Calculate` method uses `ArrayPool` internally to manage the intermediate buffers for the underlying EMAs, ensuring zero heap allocations for the user (beyond the input/output arrays).
```csharp
// Allocate buffers once
double[] source = new double[200000];
double[] temaOutput = new double[200000];
// Zero heap allocation during calculation
Tema.Calculate(source.AsSpan(), temaOutput.AsSpan(), period: 50);
```
### Handling Invalid Values
`Tema` delegates value handling to the underlying `Ema` instances, which use **last-value substitution** for `NaN` or `Infinity`. This ensures continuity and stability in the output series.
## Interpretation Details
* **Trend Direction:** Price above TEMA indicates an uptrend; price below indicates a downtrend.
* **Signal Line:** TEMA is often used as a signal line for other indicators due to its speed.
* **Crossovers:** TEMA crossovers with price or other averages provide very early entry/exit signals.
* **Volatility:** Due to its speed, TEMA can be volatile in choppy markets.
## Limitations and Considerations
* **Overshoot:** Like DEMA, TEMA can overshoot price action during sudden, sharp reversals.
* **Noise:** Its extreme responsiveness makes it susceptible to market noise and false signals in sideways markets.
* **Complexity:** The triple calculation is computationally more expensive than SMA or EMA, though negligible on modern hardware.
## References
1. Mulloy, P.G. (1994). "Smoothing Data with Faster Moving Averages." *Technical Analysis of Stocks & Commodities*, 12(1).
2. Achelis, S.B. (2000). *Technical Analysis from A to Z*. McGraw-Hill.