mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-09 22:40:57 +00:00
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:
@@ -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
|
||||
|
||||
# Double Exponential Moving Average (DEMA) 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 DEMA indicator, including mathematical formulas and interpretation, please refer to [Dema.md](Dema.md).
|
||||
|
||||
The **Double Exponential Moving Average (DEMA)** is a technical indicator designed to reduce the lag associated with traditional moving averages. It combines a single EMA and a double EMA to achieve higher 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 DEMA 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 DEMA (Period 3) ---");
|
||||
var demaBatch = new Dema(3);
|
||||
var resultBatch = demaBatch.Update(manualData);
|
||||
|
||||
PrintSeries(resultBatch, 5);
|
||||
|
||||
#!markdown
|
||||
|
||||
### Streaming Processing
|
||||
Streaming processing updates the DEMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially.
|
||||
|
||||
#!csharp
|
||||
|
||||
Console.WriteLine("\n--- Streaming DEMA (Period 3) ---");
|
||||
var demaStream = new Dema(3);
|
||||
|
||||
foreach (var item in manualData)
|
||||
{
|
||||
var result = demaStream.Update(item);
|
||||
Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, DEMA: {result.Value:F2}, IsHot: {demaStream.IsHot}");
|
||||
}
|
||||
|
||||
// Verify that the last values match
|
||||
var batchLast = resultBatch.Last().Value;
|
||||
var streamLast = demaStream.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 demaIntra = new Dema(3);
|
||||
|
||||
// 1. Process the first 4 bars normally
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
demaIntra.Update(manualData[i]);
|
||||
}
|
||||
Console.WriteLine($"After 4th bar: {demaIntra.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);
|
||||
demaIntra.Update(update1, isNew: true); // First update for this bar is "New"
|
||||
Console.WriteLine($"Update 1 (104.0): {demaIntra.Value.Value:F2}");
|
||||
|
||||
// Update 2: Price moves to 106.0 (Same time, same bar)
|
||||
var update2 = new TValue(manualData[4].Time, 106.0);
|
||||
demaIntra.Update(update2, isNew: false); // Not new, just an update
|
||||
Console.WriteLine($"Update 2 (106.0): {demaIntra.Value.Value:F2}");
|
||||
|
||||
// Update 3: Final Close at 105.0
|
||||
var update3 = manualData[4];
|
||||
demaIntra.Update(update3, isNew: false); // Final update
|
||||
Console.WriteLine($"Update 3 (105.0): {demaIntra.Value.Value:F2}");
|
||||
|
||||
// Verify match with batch result
|
||||
Console.WriteLine($"Match with Batch: {Math.Abs(demaIntra.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 demaLargeBatch = new Dema(20);
|
||||
var batchLargeResult = demaLargeBatch.Update(closeSeries);
|
||||
Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}");
|
||||
|
||||
// Streaming
|
||||
var demaLargeStream = new Dema(20);
|
||||
TValue lastStreamVal = default;
|
||||
foreach(var item in closeSeries)
|
||||
{
|
||||
lastStreamVal = demaLargeStream.Update(item);
|
||||
}
|
||||
Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}");
|
||||
|
||||
#!markdown
|
||||
|
||||
## 4. Handling Invalid Values (NaN/Infinity)
|
||||
|
||||
`Dema` 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 DEMA
|
||||
var demaNaN = new Dema(10);
|
||||
|
||||
// Feed valid values first
|
||||
demaNaN.Update(new TValue(DateTime.Now, 100.0));
|
||||
demaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0));
|
||||
Console.WriteLine($"After valid values: {demaNaN.Value.Value:F2}");
|
||||
|
||||
// Feed NaN - should use last valid value (110)
|
||||
var resultAfterNaN = demaNaN.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 = demaNaN.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 = demaNaN.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 demaBatchNaN = new Dema(3);
|
||||
var resultsWithNaN = demaBatchNaN.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)})");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
public class DemaIndicator : 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 Dema? ma;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
private int _warmupBarIndex = -1;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"DEMA {Period}:{SourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/dema/Dema.Quantower.cs";
|
||||
|
||||
public DemaIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
SourceName = Source.ToString();
|
||||
Name = "DEMA - Double Exponential Moving Average";
|
||||
Description = "Double Exponential Moving Average";
|
||||
Series = new(name: $"DEMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(Series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
ma = new Dema(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class DemaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dema_Matches_ManualCalculation()
|
||||
{
|
||||
// Arrange
|
||||
int period = 10;
|
||||
var dema = new Dema(period);
|
||||
var ema1 = new Ema(period);
|
||||
var ema2 = new Ema(period);
|
||||
var r = new Random(123);
|
||||
|
||||
// Act & Assert
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double val = r.NextDouble() * 100;
|
||||
var tVal = new TValue(DateTime.Now.AddMinutes(i), val);
|
||||
|
||||
var dVal = dema.Update(tVal);
|
||||
|
||||
var e1Val = ema1.Update(tVal);
|
||||
var e2Val = ema2.Update(e1Val);
|
||||
double expected = 2 * e1Val.Value - e2Val.Value;
|
||||
|
||||
Assert.Equal(expected, dVal.Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticCalculate_Matches_ObjectUpdate()
|
||||
{
|
||||
// Arrange
|
||||
int period = 10;
|
||||
var source = new TSeries();
|
||||
var r = new Random(123);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.Now.AddMinutes(i), r.NextDouble() * 100));
|
||||
}
|
||||
|
||||
// Act
|
||||
var demaSeries = Dema.Calculate(source, period);
|
||||
var demaObj = new Dema(period);
|
||||
|
||||
// Assert
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var val = demaObj.Update(source[i]);
|
||||
Assert.Equal(val.Value, demaSeries[i].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroAllocCalculate_Matches_ObjectUpdate()
|
||||
{
|
||||
// Arrange
|
||||
int period = 10;
|
||||
int count = 100;
|
||||
var source = new double[count];
|
||||
var output = new double[count];
|
||||
var r = new Random(123);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
source[i] = r.NextDouble() * 100;
|
||||
}
|
||||
|
||||
// Act
|
||||
Dema.Calculate(source, output, period);
|
||||
var demaObj = new Dema(period);
|
||||
|
||||
// Assert
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var val = demaObj.Update(new TValue(DateTime.Now, source[i]));
|
||||
Assert.Equal(val.Value, output[i], 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alpha_Constructor_Matches_Period_Constructor()
|
||||
{
|
||||
// Arrange
|
||||
int period = 10;
|
||||
double alpha = 2.0 / (period + 1);
|
||||
var demaPeriod = new Dema(period);
|
||||
var demaAlpha = new Dema(alpha);
|
||||
var r = new Random(123);
|
||||
|
||||
// Act & Assert
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double val = r.NextDouble() * 100;
|
||||
var tVal = new TValue(DateTime.Now.AddMinutes(i), val);
|
||||
|
||||
var pVal = demaPeriod.Update(tVal);
|
||||
var aVal = demaAlpha.Update(tVal);
|
||||
|
||||
Assert.Equal(pVal.Value, aVal.Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticCalculate_Alpha_Matches_ObjectUpdate()
|
||||
{
|
||||
// Arrange
|
||||
double alpha = 0.15;
|
||||
var source = new TSeries();
|
||||
var r = new Random(123);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.Now.AddMinutes(i), r.NextDouble() * 100));
|
||||
}
|
||||
|
||||
// Act
|
||||
var demaSeries = Dema.Calculate(source, alpha);
|
||||
var demaObj = new Dema(alpha);
|
||||
|
||||
// Assert
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var val = demaObj.Update(source[i]);
|
||||
Assert.Equal(val.Value, demaSeries[i].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroAllocCalculate_Alpha_Matches_ObjectUpdate()
|
||||
{
|
||||
// Arrange
|
||||
double alpha = 0.15;
|
||||
int count = 100;
|
||||
var source = new double[count];
|
||||
var output = new double[count];
|
||||
var r = new Random(123);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
source[i] = r.NextDouble() * 100;
|
||||
}
|
||||
|
||||
// Act
|
||||
Dema.Calculate(source, output, alpha);
|
||||
var demaObj = new Dema(alpha);
|
||||
|
||||
// Assert
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var val = demaObj.Update(new TValue(DateTime.Now, source[i]));
|
||||
Assert.Equal(val.Value, output[i], 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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 DemaValidationTests
|
||||
{
|
||||
private readonly TBarSeries _bars;
|
||||
private readonly TSeries _data;
|
||||
private readonly List<Quote> _skenderQuotes;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public DemaValidationTests(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 DEMA (batch TSeries)
|
||||
var dema = new global::QuanTAlib.Dema(period);
|
||||
var qResult = dema.Update(_data);
|
||||
|
||||
// Calculate Skender DEMA
|
||||
var sResult = _skenderQuotes.GetDema(period).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Skender(qResult, sResult);
|
||||
}
|
||||
_output.WriteLine("DEMA 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 DEMA (batch TSeries)
|
||||
var dema = new global::QuanTAlib.Dema(period);
|
||||
var qResult = dema.Update(_data);
|
||||
|
||||
// Calculate TA-Lib DEMA
|
||||
var retCode = TALib.Functions.Dema<double>(tData, 0..^0, output, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.DemaLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Talib(qResult, output, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("DEMA 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 DEMA (batch TSeries)
|
||||
var dema = new global::QuanTAlib.Dema(period);
|
||||
var qResult = dema.Update(_data);
|
||||
|
||||
// Calculate Tulip DEMA
|
||||
var demaIndicator = Tulip.Indicators.dema;
|
||||
double[][] inputs = { tData };
|
||||
double[] options = { period };
|
||||
|
||||
// Tulip DEMA lookback is usually period-1 for EMA, but DEMA is 2*EMA - EMA(EMA)
|
||||
// Let's rely on the output length to align.
|
||||
// Tulip DEMA lookback is same as EMA lookback? No, it involves double smoothing.
|
||||
// Actually, Tulip's DEMA implementation might have a specific lookback.
|
||||
// We'll calculate it based on output length.
|
||||
|
||||
// Tulip.Indicators.dema.Run expects outputs to be sized correctly.
|
||||
// We'll use a large buffer and resize if needed, or just calculate lookback.
|
||||
// For DEMA(n), lookback is roughly n-1 (same as EMA).
|
||||
// Wait, DEMA uses EMA(EMA), so it might be 2*(n-1)?
|
||||
// Let's try with n-1 first, if it fails we adjust.
|
||||
// Actually, TA-Lib DEMA lookback is 2*(period-1).
|
||||
// Let's assume Tulip is similar.
|
||||
int lookback = 2 * (period - 1);
|
||||
double[][] outputs = { new double[tData.Length - lookback] };
|
||||
|
||||
demaIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Tulip(qResult, tResult, lookback);
|
||||
}
|
||||
_output.WriteLine("DEMA 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 DEMA (Span API)
|
||||
double[] qOutput = new double[sourceData.Length];
|
||||
global::QuanTAlib.Dema.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
|
||||
|
||||
// Calculate TA-Lib DEMA
|
||||
var retCode = TALib.Functions.Dema<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.DemaLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Talib_Span(qOutput, talibOutput, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("DEMA Span validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
// ==================== Verification Helpers ====================
|
||||
|
||||
private static void VerifyData_Skender(TSeries qSeries, List<DemaResult> 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].Dema;
|
||||
|
||||
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-6);
|
||||
}
|
||||
}
|
||||
|
||||
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-6);
|
||||
}
|
||||
}
|
||||
|
||||
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-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// DEMA: Double Exponential Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEMA reduces the lag of traditional EMA by subtracting the lag from the original EMA.
|
||||
///
|
||||
/// Calculation:
|
||||
/// EMA1 = EMA(input)
|
||||
/// EMA2 = EMA(EMA1)
|
||||
/// DEMA = 2 * EMA1 - EMA2
|
||||
///
|
||||
/// O(1) update:
|
||||
/// Uses two EMA instances, each with O(1) update complexity.
|
||||
///
|
||||
/// IsHot:
|
||||
/// Becomes true when the second EMA converges (approx. 2x EMA convergence time).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Dema
|
||||
{
|
||||
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 _p_state1 = EmaState.New();
|
||||
private EmaState _p_state2 = EmaState.New();
|
||||
|
||||
private double _lastValidValue;
|
||||
|
||||
public string Name { get; }
|
||||
public TValue Value { get; private set; }
|
||||
public bool IsHot => _state2.IsHot;
|
||||
|
||||
public Dema(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 = $"Dema({period})";
|
||||
}
|
||||
|
||||
public Dema(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 = $"Dema(α={alpha:F4})";
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state1 = _state1;
|
||||
_p_state2 = _state2;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state1 = _p_state1;
|
||||
_state2 = _p_state2;
|
||||
}
|
||||
|
||||
// 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, which is always valid)
|
||||
double e2 = Compute(e1, _alpha, _decay, ref _state2);
|
||||
|
||||
double result = 2 * e1 - e2;
|
||||
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;
|
||||
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);
|
||||
|
||||
vSpan[i] = 2 * e1 - e2;
|
||||
}
|
||||
|
||||
// Update instance state
|
||||
_state1 = s1;
|
||||
_state2 = s2;
|
||||
_p_state1 = s1;
|
||||
_p_state2 = s2;
|
||||
_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 dema = new Dema(period);
|
||||
return dema.Update(source);
|
||||
}
|
||||
|
||||
public static TSeries Calculate(TSeries source, double alpha)
|
||||
{
|
||||
var dema = new Dema(alpha);
|
||||
return dema.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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// DEMA = 2 * EMA1 - EMA2
|
||||
output[i] = 2 * e1 - e2;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_state1 = EmaState.New();
|
||||
_state2 = EmaState.New();
|
||||
_p_state1 = EmaState.New();
|
||||
_p_state2 = EmaState.New();
|
||||
_lastValidValue = 0;
|
||||
Value = default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# DEMA: Double Exponential Moving Average
|
||||
|
||||
## Overview and Purpose
|
||||
|
||||
The Double Exponential Moving Average (DEMA) is a technical indicator developed by Patrick Mulloy in 1994 to reduce the lag associated with traditional moving averages. Despite its name, DEMA is not simply a double smoothing of the price (like a double EMA would be). Instead, it uses a combination of a single EMA and a double EMA to subtract the lag inherent in the original EMA.
|
||||
|
||||
DEMA responds more quickly to price changes than a standard EMA or SMA, making it popular among traders who need faster signals for trend reversals or breakouts. It effectively filters out noise while maintaining high responsiveness, offering a "best of both worlds" solution between smoothing and lag reduction.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
* **Lag Reduction:** DEMA's primary goal is to minimize the delay between price action and the indicator's response.
|
||||
* **Composite Calculation:** It combines a single EMA and a double EMA (EMA of EMA) to achieve its unique characteristics.
|
||||
* **High Responsiveness:** Reacts faster to market moves than traditional averages, potentially offering earlier entry and exit signals.
|
||||
* **Trend Identification:** Like other moving averages, it helps identify the direction of the trend and potential support/resistance levels.
|
||||
|
||||
## Common Settings and Parameters
|
||||
|
||||
| Parameter | Default | Function | When to Adjust |
|
||||
|-----------|---------|----------|---------------|
|
||||
| Length | 20 | Controls responsiveness/smoothness | Shorter for scalping/day trading, longer for swing/position trading |
|
||||
| Source | Close | Data point used for calculation | Change to HL2 or HLC3 for more balanced price representation |
|
||||
| Alpha | 2/(length+1) | Determines weighting decay | Direct alpha manipulation allows for precise tuning beyond standard length settings |
|
||||
|
||||
## Calculation and Mathematical Foundation
|
||||
|
||||
**Simplified explanation:**
|
||||
DEMA takes a standard EMA, calculates a second EMA on that result, and then combines them using a specific formula to cancel out the lag.
|
||||
|
||||
**Technical formula:**
|
||||
$$DEMA = 2 \times EMA_1 - EMA_2$$
|
||||
|
||||
Where:
|
||||
|
||||
* $EMA_1 = EMA(Price)$
|
||||
* $EMA_2 = EMA(EMA_1)$
|
||||
|
||||
The formula can be derived from the error correction principle. If $EMA_1$ has a lag error $E$, then $EMA_2$ (being an EMA of $EMA_1$) will have roughly twice the lag error ($2E$).
|
||||
The difference $EMA_1 - EMA_2$ represents the estimated lag error.
|
||||
Adding this error term back to $EMA_1$ gives:
|
||||
$$DEMA = EMA_1 + (EMA_1 - EMA_2) = 2 \times EMA_1 - EMA_2$$
|
||||
|
||||
> 🔍 **Technical Note:** The implementation leverages the optimized `Ema` class, which uses **Hunter's bias compensation**. This ensures that both the primary and secondary EMAs are initialized correctly from the very first data point, providing accurate DEMA values immediately without a long warmup period.
|
||||
|
||||
## C# Implementation
|
||||
|
||||
The library provides a high-performance implementation of DEMA that supports both standard period-based initialization and direct alpha specification.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```csharp
|
||||
using QuanTAlib;
|
||||
|
||||
// Initialize with period 14
|
||||
var dema = new Dema(14);
|
||||
|
||||
// Or initialize with specific alpha
|
||||
var demaAlpha = new Dema(0.15);
|
||||
|
||||
// Streaming update
|
||||
TValue result = dema.Update(new TValue(time, price));
|
||||
Console.WriteLine($"Current DEMA: {result.Value}");
|
||||
|
||||
// Batch calculation (TSeries API)
|
||||
TSeries source = ...;
|
||||
TSeries results = Dema.Calculate(source, 14);
|
||||
|
||||
// High-performance Span API (zero allocation)
|
||||
double[] prices = new double[10000];
|
||||
double[] output = new double[10000];
|
||||
Dema.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 buffer for the first EMA, ensuring zero heap allocations for the user (beyond the input/output arrays).
|
||||
|
||||
```csharp
|
||||
// Allocate buffers once
|
||||
double[] source = new double[200000];
|
||||
double[] demaOutput = new double[200000];
|
||||
|
||||
// Zero heap allocation during calculation
|
||||
Dema.Calculate(source.AsSpan(), demaOutput.AsSpan(), period: 50);
|
||||
```
|
||||
|
||||
### Handling Invalid Values
|
||||
|
||||
`Dema` 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 DEMA suggests an uptrend; price below suggests a downtrend.
|
||||
* **Crossovers:** DEMA crossovers (e.g., DEMA(10) crossing DEMA(20)) can provide faster signals than EMA crossovers.
|
||||
* **Support/Resistance:** DEMA can act as dynamic support or resistance, often hugging the price action closer than an EMA.
|
||||
* **Divergence:** Divergence between price and DEMA can signal potential reversals.
|
||||
|
||||
## Limitations and Considerations
|
||||
|
||||
* **Overshoot:** Because DEMA subtracts lag, it can sometimes overshoot price action during sharp reversals.
|
||||
* **Noise Sensitivity:** Its high responsiveness means it may be more susceptible to market noise than a standard EMA or SMA.
|
||||
* **Whipsaws:** In sideways markets, the reduced lag can lead to more frequent false signals (whipsaws).
|
||||
|
||||
## References
|
||||
|
||||
1. Mulloy, P.G. (1994). "Smoothing Data with Faster Moving Averages." *Technical Analysis of Stocks & Commodities*, 12(1).
|
||||
2. Murphy, J.J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance.
|
||||
@@ -19,9 +19,9 @@ public class EmaValidationTests
|
||||
{
|
||||
_output = output;
|
||||
|
||||
// 1. Generate 1000 records using GBM feed
|
||||
// 1. Generate 5000 records using GBM feed
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
|
||||
_bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 2. Extract Close TSeries
|
||||
_data = _bars.Close;
|
||||
|
||||
+64
-62
@@ -7,23 +7,25 @@ namespace QuanTAlib;
|
||||
/// EMA: Exponential Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EMA needs very short history buffer and calculates the EMA value using just the
|
||||
/// previous EMA value. The weight of the new datapoint (alpha) is alpha = 2 / (period + 1)
|
||||
/// EMA applies exponential weighting to data points, giving more weight to recent values.
|
||||
/// Uses a single state variable for O(1) complexity per update.
|
||||
///
|
||||
/// Key characteristics:
|
||||
/// - Uses no buffer, relying only on the previous EMA value.
|
||||
/// - The weight of new data points is calculated as alpha = 2 / (period + 1).
|
||||
/// - Provides a balance between responsiveness and smoothing. No overshooting. Significant lag
|
||||
/// Calculation:
|
||||
/// alpha = 2 / (period + 1)
|
||||
/// EMA_new = EMA_old + alpha * (newest - EMA_old)
|
||||
///
|
||||
/// Calculation method:
|
||||
/// This implementation can use SMA for the first Period bars as a seeding value for EMA when useSma is true.
|
||||
/// Initialization:
|
||||
/// Uses a compensator factor to correct early-stage bias (when n < period).
|
||||
/// Output = EMA_state / (1 - (1-alpha)^n)
|
||||
///
|
||||
/// Sources:
|
||||
/// - https://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:moving_averages
|
||||
/// - https://www.investopedia.com/ask/answers/122314/what-exponential-moving-average-ema-formula-and-how-ema-calculated.asp
|
||||
/// - https://blog.fugue88.ws/archives/2017-01/The-correct-way-to-start-an-Exponential-Moving-Average-EMA
|
||||
/// O(1) update:
|
||||
/// No buffer required, only previous EMA value and compensator state.
|
||||
///
|
||||
/// IsHot:
|
||||
/// Becomes true when n = ln(0.05) / ln(1 - alpha)
|
||||
/// </remarks>
|
||||
public class Ema
|
||||
[SkipLocalsInit]
|
||||
public sealed class Ema
|
||||
{
|
||||
private struct State
|
||||
{
|
||||
@@ -99,6 +101,55 @@ public class Ema
|
||||
private const double COVERAGE_THRESHOLD = 0.05;
|
||||
private const double COMPENSATOR_THRESHOLD = 1e-10;
|
||||
|
||||
[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, _decay, ref _state);
|
||||
Value = new TValue(input.Time, val);
|
||||
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);
|
||||
var sourceValues = source.Values;
|
||||
var sourceTimes = source.Times;
|
||||
|
||||
State state = _state;
|
||||
double lastValidValue = _lastValidValue;
|
||||
|
||||
CalculateCore(sourceValues, vSpan, _alpha, ref state, ref lastValidValue);
|
||||
|
||||
_state = state;
|
||||
_lastValidValue = lastValidValue;
|
||||
|
||||
sourceTimes.CopyTo(tSpan);
|
||||
|
||||
_p_state = _state;
|
||||
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 State state)
|
||||
{
|
||||
@@ -172,55 +223,6 @@ public class Ema
|
||||
}
|
||||
}
|
||||
|
||||
[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, _decay, ref _state);
|
||||
Value = new TValue(input.Time, val);
|
||||
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);
|
||||
var sourceValues = source.Values;
|
||||
var sourceTimes = source.Times;
|
||||
|
||||
State state = _state;
|
||||
double lastValidValue = _lastValidValue;
|
||||
|
||||
CalculateCore(sourceValues, vSpan, _alpha, ref state, ref lastValidValue);
|
||||
|
||||
_state = state;
|
||||
_lastValidValue = lastValidValue;
|
||||
|
||||
sourceTimes.CopyTo(tSpan);
|
||||
|
||||
_p_state = _state;
|
||||
Value = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates EMA for the entire series using a new instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,9 +19,9 @@ public class SmaValidationTests
|
||||
{
|
||||
_output = output;
|
||||
|
||||
// 1. Generate 1000 records using GBM feed
|
||||
// 1. Generate 5000 records using GBM feed
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
|
||||
_bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 2. Extract Close TSeries
|
||||
_data = _bars.Close;
|
||||
|
||||
+9
-18
@@ -11,27 +11,18 @@ namespace QuanTAlib;
|
||||
/// SMA: Simple Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SMA calculates the arithmetic mean of the last N values.
|
||||
/// Uses a RingBuffer for storage and manual running sum for O(1) operations.
|
||||
/// SMA calculates the arithmetic mean of the last n values.
|
||||
/// Uses a RingBuffer for storage and manual running sum for O(1) complexity per update.
|
||||
///
|
||||
/// Key characteristics:
|
||||
/// - Equal weighting of all values in the period
|
||||
/// - No lag bias - responds equally to all values in window
|
||||
/// - Smooth output with good noise reduction
|
||||
/// - O(1) time complexity for both update and bar correction
|
||||
/// - O(1) space complexity for state save/restore (scalars only)
|
||||
/// Calculation:
|
||||
/// SMA = (P_n + P_(n-1) + ... + P_1) / n
|
||||
///
|
||||
/// Calculation method:
|
||||
/// SMA = Sum(values in period) / period
|
||||
/// O(1) update:
|
||||
/// S_new = S_old - oldest + newest
|
||||
/// SMA = S_new / n
|
||||
///
|
||||
/// Bar correction (isNew=false):
|
||||
/// - Restores to state after last isNew=true
|
||||
/// - Then replaces the last value with new correction value
|
||||
/// - All O(1) using scalar state
|
||||
///
|
||||
/// Sources:
|
||||
/// - https://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:moving_averages
|
||||
/// - https://www.investopedia.com/terms/s/sma.asp
|
||||
/// IsHot:
|
||||
/// Becomes true when the buffer is full (period samples processed).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Sma
|
||||
|
||||
@@ -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)})");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,6 +1,4 @@
|
||||
# todo
|
||||
|
||||
- __DEMA__ (Double Exponential Moving Average)
|
||||
- __TEMA__ (Triple Exponential Moving Average)
|
||||
- __KAMA__ (Kaufman Adaptive Moving Average)
|
||||
- __T3__ (T3)
|
||||
|
||||
@@ -19,7 +19,7 @@ The **Triangular Moving Average (TRIMA)** is a weighted moving average where the
|
||||
#!csharp
|
||||
|
||||
// Reference the library
|
||||
#r "..\..\bin\QuanTAlib.dll"
|
||||
#r "..\..\bin\Debug\net10.0\QuanTAlib.dll"
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -20,9 +20,9 @@ public class TrimaValidationTests
|
||||
{
|
||||
_output = output;
|
||||
|
||||
// 1. Generate 1000 records using GBM feed
|
||||
// 1. Generate 5000 records using GBM feed
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
|
||||
_bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 2. Extract Close TSeries
|
||||
_data = _bars.Close;
|
||||
|
||||
@@ -8,17 +8,19 @@ namespace QuanTAlib;
|
||||
/// TRIMA: Triangular Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TRIMA is a weighted moving average where weights increase linearly to the middle
|
||||
/// and then decrease. It is equivalent to a double SMA: SMA(SMA(period1), period2).
|
||||
/// TRIMA applies triangular weighting to data points, emphasizing the middle of the window.
|
||||
/// Equivalent to a double SMA: SMA(SMA(period1), period2).
|
||||
///
|
||||
/// Calculation:
|
||||
/// period1 = period / 2 + 1
|
||||
/// period2 = (period + 1) / 2
|
||||
/// p1 = period / 2 + 1
|
||||
/// p2 = (period + 1) / 2
|
||||
/// TRIMA = SMA(SMA(input, p1), p2)
|
||||
///
|
||||
/// Characteristics:
|
||||
/// - Smoother than SMA, higher lag
|
||||
/// - O(1) time complexity
|
||||
/// - O(period) space complexity
|
||||
/// O(1) update:
|
||||
/// Uses two SMA instances, each with O(1) update complexity.
|
||||
///
|
||||
/// IsHot:
|
||||
/// Becomes true when the buffer is full (period samples processed).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Trima
|
||||
|
||||
@@ -19,9 +19,9 @@ public class WmaValidationTests
|
||||
{
|
||||
_output = output;
|
||||
|
||||
// 1. Generate 1000 records using GBM feed
|
||||
// 1. Generate 5000 records using GBM feed
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2);
|
||||
_bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// 2. Extract Close TSeries
|
||||
_data = _bars.Close;
|
||||
|
||||
@@ -20,6 +20,9 @@ namespace QuanTAlib;
|
||||
/// O(1) update:
|
||||
/// S_new = S - oldest + newest
|
||||
/// W_new = W - S_old + n*newest
|
||||
///
|
||||
/// IsHot:
|
||||
/// Becomes true when the buffer is full (period samples processed).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Wma
|
||||
|
||||
+99
-4
@@ -63,6 +63,12 @@ public class IndicatorBenchmarks
|
||||
private double[][] _tulipTrimaInputs = null!;
|
||||
private double[] _tulipTrimaOptions = null!;
|
||||
private double[][] _tulipTrimaOutputs = null!;
|
||||
private double[][] _tulipDemaInputs = null!;
|
||||
private double[] _tulipDemaOptions = null!;
|
||||
private double[][] _tulipDemaOutputs = null!;
|
||||
private double[][] _tulipTemaInputs = null!;
|
||||
private double[] _tulipTemaOptions = null!;
|
||||
private double[][] _tulipTemaOutputs = null!;
|
||||
|
||||
// Pre-allocated outputs for QuanTAlib Span API
|
||||
private double[] _quantalibOutput = null!;
|
||||
@@ -113,6 +119,16 @@ public class IndicatorBenchmarks
|
||||
_tulipTrimaOptions = new double[] { Period };
|
||||
_tulipTrimaOutputs = new[] { new double[BarCount - smaLookback] };
|
||||
|
||||
int demaLookback = 2 * (Period - 1);
|
||||
_tulipDemaInputs = new[] { _closeValues };
|
||||
_tulipDemaOptions = new double[] { Period };
|
||||
_tulipDemaOutputs = new[] { new double[BarCount - demaLookback] };
|
||||
|
||||
int temaLookback = 3 * (Period - 1);
|
||||
_tulipTemaInputs = new[] { _closeValues };
|
||||
_tulipTemaOptions = new double[] { Period };
|
||||
_tulipTemaOutputs = new[] { new double[BarCount - temaLookback] };
|
||||
|
||||
// Pre-allocate QuanTAlib output
|
||||
_quantalibOutput = new double[BarCount];
|
||||
}
|
||||
@@ -123,7 +139,7 @@ public class IndicatorBenchmarks
|
||||
public void QuanTAlib_Sma_Span() => Sma.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("SMA")]
|
||||
[Benchmark(Description = "QuanTAlib SMA (TSeries)")]
|
||||
[Benchmark(Description = "QuanTAlib SMA (Batch)")]
|
||||
public TSeries QuanTAlib_Sma_TSeries() => Sma.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("SMA")]
|
||||
@@ -163,7 +179,7 @@ public class IndicatorBenchmarks
|
||||
public void QuanTAlib_Ema_Span() => Ema.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("EMA")]
|
||||
[Benchmark(Description = "QuanTAlib EMA (TSeries)")]
|
||||
[Benchmark(Description = "QuanTAlib EMA (Batch)")]
|
||||
public TSeries QuanTAlib_Ema_TSeries() => Ema.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("EMA")]
|
||||
@@ -203,7 +219,7 @@ public class IndicatorBenchmarks
|
||||
public void QuanTAlib_Wma_Span() => Wma.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("WMA")]
|
||||
[Benchmark(Description = "QuanTAlib WMA (TSeries)")]
|
||||
[Benchmark(Description = "QuanTAlib WMA (Batch)")]
|
||||
public TSeries QuanTAlib_Wma_TSeries() => Wma.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("WMA")]
|
||||
@@ -243,7 +259,7 @@ public class IndicatorBenchmarks
|
||||
public void QuanTAlib_Trima_Span() => Trima.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("TRIMA")]
|
||||
[Benchmark(Description = "QuanTAlib TRIMA (TSeries)")]
|
||||
[Benchmark(Description = "QuanTAlib TRIMA (Batch)")]
|
||||
public TSeries QuanTAlib_Trima_TSeries() => Trima.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("TRIMA")]
|
||||
@@ -265,4 +281,83 @@ public class IndicatorBenchmarks
|
||||
[Benchmark(Description = "TALib TRIMA")]
|
||||
public Core.RetCode TALib_Trima() => TALib.Functions.Trima<double>(_closeValues, 0..^0, _talibOutput, out _, Period);
|
||||
|
||||
// ==================== DEMA ====================
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "QuanTAlib DEMA (Span)")]
|
||||
public void QuanTAlib_Dema_Span() => Dema.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "QuanTAlib DEMA (Batch)")]
|
||||
public TSeries QuanTAlib_Dema_TSeries() => Dema.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "QuanTAlib DEMA (Streaming)")]
|
||||
public void QuanTAlib_Dema_Streaming()
|
||||
{
|
||||
var dema = new Dema(Period);
|
||||
for (int i = 0; i < _closeValues.Length; i++)
|
||||
{
|
||||
_quantalibOutput[i] = dema.Update(new TValue(_closeTseries.Times[i], _closeValues[i])).Value;
|
||||
}
|
||||
}
|
||||
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "Tulip DEMA")]
|
||||
public void Tulip_Dema() => Tulip.Indicators.dema.Run(_tulipDemaInputs, _tulipDemaOptions, _tulipDemaOutputs);
|
||||
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "TALib DEMA")]
|
||||
public Core.RetCode TALib_Dema() => TALib.Functions.Dema<double>(_closeValues, 0..^0, _talibOutput, out _, Period);
|
||||
|
||||
[BenchmarkCategory("DEMA")]
|
||||
[Benchmark(Description = "Skender DEMA")]
|
||||
public double Skender_Dema()
|
||||
{
|
||||
double sum = 0;
|
||||
foreach (var r in _quotes.GetDema(Period))
|
||||
{
|
||||
sum += (double)(r.Dema ?? 0);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// ==================== TEMA ====================
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "QuanTAlib TEMA (Span)")]
|
||||
public void QuanTAlib_Tema_Span() => Tema.Calculate(_closeValues.AsSpan(), _quantalibOutput.AsSpan(), Period);
|
||||
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "QuanTAlib TEMA (Batch)")]
|
||||
public TSeries QuanTAlib_Tema_TSeries() => Tema.Calculate(_closeTseries, Period);
|
||||
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "QuanTAlib TEMA (Streaming)")]
|
||||
public void QuanTAlib_Tema_Streaming()
|
||||
{
|
||||
var tema = new Tema(Period);
|
||||
for (int i = 0; i < _closeValues.Length; i++)
|
||||
{
|
||||
_quantalibOutput[i] = tema.Update(new TValue(_closeTseries.Times[i], _closeValues[i])).Value;
|
||||
}
|
||||
}
|
||||
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "Tulip TEMA")]
|
||||
public void Tulip_Tema() => Tulip.Indicators.tema.Run(_tulipTemaInputs, _tulipTemaOptions, _tulipTemaOutputs);
|
||||
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "TALib TEMA")]
|
||||
public Core.RetCode TALib_Tema() => TALib.Functions.Tema<double>(_closeValues, 0..^0, _talibOutput, out _, Period);
|
||||
|
||||
[BenchmarkCategory("TEMA")]
|
||||
[Benchmark(Description = "Skender TEMA")]
|
||||
public double Skender_Tema()
|
||||
{
|
||||
double sum = 0;
|
||||
foreach (var r in _quotes.GetTema(Period))
|
||||
{
|
||||
sum += (double)(r.Tema ?? 0);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace QuanTAlib.Tests;
|
||||
|
||||
public class IndicatorExtensionsTests
|
||||
{
|
||||
private class TestIndicator : Indicator
|
||||
private sealed class TestIndicator : Indicator
|
||||
{
|
||||
public TestIndicator()
|
||||
{
|
||||
@@ -15,11 +15,11 @@ public class IndicatorExtensionsTests
|
||||
}
|
||||
}
|
||||
|
||||
private class TestCoordinatesConverter : ICoordinatesConverter
|
||||
private sealed class TestCoordinatesConverter : ICoordinatesConverter
|
||||
{
|
||||
private readonly DateTime _time;
|
||||
public TestCoordinatesConverter(DateTime time) => _time = time;
|
||||
|
||||
|
||||
public DateTime GetTime(int x) => _time;
|
||||
public double GetChartX(DateTime time) => 0;
|
||||
public double GetChartY(double value) => 0;
|
||||
@@ -28,8 +28,8 @@ public class IndicatorExtensionsTests
|
||||
[Fact]
|
||||
public void DataSourceInputAttribute_HasCorrectDefaults()
|
||||
{
|
||||
var attr = new IndicatorExtensions.DataSourceInputAttribute();
|
||||
|
||||
IndicatorExtensions.DataSourceInputAttribute attr = new();
|
||||
|
||||
Assert.Equal("Data source", attr.Name);
|
||||
Assert.Equal(20, attr.SortIndex);
|
||||
Assert.NotNull(attr.Variants);
|
||||
@@ -39,39 +39,45 @@ public class IndicatorExtensionsTests
|
||||
[Fact]
|
||||
public void GetInputValue_ReturnsCorrectValues_ForSourceTypes()
|
||||
{
|
||||
var indicator = new TestIndicator();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
TestIndicator indicator = new();
|
||||
DateTime now = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Open=100, High=110, Low=90, Close=105, Volume=1000
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
|
||||
|
||||
const double open = 100;
|
||||
const double high = 110;
|
||||
const double low = 90;
|
||||
const double close = 105;
|
||||
const double volume = 1000;
|
||||
|
||||
indicator.HistoricalData.AddBar(now, open, high, low, close, volume);
|
||||
|
||||
// Ensure Count is updated (mock implementation detail)
|
||||
// The mock HistoricalData.Count reflects added items.
|
||||
// Indicator.Count => HistoricalData.Count.
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.NewBar);
|
||||
|
||||
UpdateArgs args = new(UpdateReason.NewBar);
|
||||
|
||||
// Test each SourceType
|
||||
Assert.Equal(100, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Open).Value);
|
||||
Assert.Equal(110, IndicatorExtensions.GetInputValue(indicator, args, SourceType.High).Value);
|
||||
Assert.Equal(90, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Low).Value);
|
||||
Assert.Equal(105, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Close).Value);
|
||||
|
||||
Assert.Equal(open, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Open).Value);
|
||||
Assert.Equal(high, IndicatorExtensions.GetInputValue(indicator, args, SourceType.High).Value);
|
||||
Assert.Equal(low, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Low).Value);
|
||||
Assert.Equal(close, IndicatorExtensions.GetInputValue(indicator, args, SourceType.Close).Value);
|
||||
|
||||
// HL2 = (110 + 90) / 2 = 100
|
||||
Assert.Equal(100, IndicatorExtensions.GetInputValue(indicator, args, SourceType.HL2).Value);
|
||||
|
||||
|
||||
// OC2 = (100 + 105) / 2 = 102.5
|
||||
Assert.Equal(102.5, IndicatorExtensions.GetInputValue(indicator, args, SourceType.OC2).Value);
|
||||
|
||||
|
||||
// OHL3 = (100 + 110 + 90) / 3 = 100
|
||||
Assert.Equal(100, IndicatorExtensions.GetInputValue(indicator, args, SourceType.OHL3).Value);
|
||||
|
||||
|
||||
// HLC3 = (110 + 90 + 105) / 3 = 101.666...
|
||||
Assert.Equal(101.66666666666667, IndicatorExtensions.GetInputValue(indicator, args, SourceType.HLC3).Value, 5);
|
||||
|
||||
|
||||
// OHLC4 = (100 + 110 + 90 + 105) / 4 = 101.25
|
||||
Assert.Equal(101.25, IndicatorExtensions.GetInputValue(indicator, args, SourceType.OHLC4).Value);
|
||||
|
||||
|
||||
// HLCC4 = (110 + 90 + 105 + 105) / 4 = 102.5
|
||||
Assert.Equal(102.5, IndicatorExtensions.GetInputValue(indicator, args, SourceType.HLCC4).Value);
|
||||
}
|
||||
@@ -79,20 +85,26 @@ public class IndicatorExtensionsTests
|
||||
[Fact]
|
||||
public void GetInputBar_ReturnsCorrectBar()
|
||||
{
|
||||
var indicator = new TestIndicator();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
|
||||
var args = new UpdateArgs(UpdateReason.NewBar);
|
||||
TestIndicator indicator = new();
|
||||
DateTime now = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
const double open = 100;
|
||||
const double high = 110;
|
||||
const double low = 90;
|
||||
const double close = 105;
|
||||
const double volume = 1000;
|
||||
|
||||
indicator.HistoricalData.AddBar(now, open, high, low, close, volume);
|
||||
UpdateArgs args = new(UpdateReason.NewBar);
|
||||
|
||||
var bar = IndicatorExtensions.GetInputBar(indicator, args);
|
||||
|
||||
Assert.Equal(now, bar.AsDateTime);
|
||||
Assert.Equal(100, bar.Open);
|
||||
Assert.Equal(110, bar.High);
|
||||
Assert.Equal(90, bar.Low);
|
||||
Assert.Equal(105, bar.Close);
|
||||
Assert.Equal(1000, bar.Volume);
|
||||
Assert.Equal(open, bar.Open);
|
||||
Assert.Equal(high, bar.High);
|
||||
Assert.Equal(low, bar.Low);
|
||||
Assert.Equal(close, bar.Close);
|
||||
Assert.Equal(volume, bar.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -147,6 +159,6 @@ public class IndicatorExtensionsTests
|
||||
IndicatorExtensions.DrawText(indicator, args, "Test Text");
|
||||
|
||||
// Assert that we reached the end without throwing
|
||||
Assert.True(true);
|
||||
// If we got here, no exception was thrown
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user