mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 22:08:05 +00:00
Add TRIMA implementation and benchmarks; optimize WMA with SIMD
- Introduced `TrimaVector` class for multi-period Triangular Moving Average (TRIMA) calculations, optimized for SIMD. - Implemented last-value substitution for invalid inputs in TRIMA. - Added methods for calculating TRIMA for entire series and individual updates. - Enhanced `Wma` class with periodic resync to prevent floating-point drift and introduced SIMD optimizations for performance. - Updated benchmark suite to include TRIMA calculations alongside existing SMA, EMA, and WMA benchmarks.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!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
|
||||
|
||||
# Triangular Moving Average (TRIMA) 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.
|
||||
|
||||
The **Triangular Moving Average (TRIMA)** is a weighted moving average where the weights increase linearly to the middle of the period and then decrease. It is equivalent to a double-smoothed SMA (SMA of an SMA).
|
||||
|
||||
**Key characteristics:**
|
||||
- Triangular weighting (emphasis on middle values)
|
||||
- Smoother than SMA
|
||||
- Higher lag than SMA
|
||||
- O(1) update complexity
|
||||
|
||||
#!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.
|
||||
|
||||
#!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 TRIMA (Period 3) ---");
|
||||
var trimaBatch = new Trima(3);
|
||||
var resultBatch = trimaBatch.Update(manualData);
|
||||
|
||||
PrintSeries(resultBatch, 5);
|
||||
|
||||
#!markdown
|
||||
|
||||
### Streaming Processing
|
||||
|
||||
#!csharp
|
||||
|
||||
Console.WriteLine("\n--- Streaming TRIMA (Period 3) ---");
|
||||
var trimaStream = new Trima(3);
|
||||
|
||||
foreach (var item in manualData)
|
||||
{
|
||||
var result = trimaStream.Update(item);
|
||||
Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, TRIMA: {result.Value:F2}, IsHot: {trimaStream.IsHot}");
|
||||
}
|
||||
|
||||
// Verify that the last values match
|
||||
var batchLast = resultBatch.Last().Value;
|
||||
var streamLast = trimaStream.Value.Value;
|
||||
Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})");
|
||||
|
||||
#!markdown
|
||||
|
||||
## 2. 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.");
|
||||
|
||||
#!markdown
|
||||
|
||||
### Batch vs. Streaming Performance on Large Data
|
||||
|
||||
#!csharp
|
||||
|
||||
// Batch
|
||||
var trimaLargeBatch = new Trima(20);
|
||||
var batchLargeResult = trimaLargeBatch.Update(closeSeries);
|
||||
Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}");
|
||||
|
||||
// Streaming
|
||||
var trimaLargeStream = new Trima(20);
|
||||
TValue lastStreamVal = default;
|
||||
foreach(var item in closeSeries)
|
||||
{
|
||||
lastStreamVal = trimaLargeStream.Update(item);
|
||||
}
|
||||
Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}");
|
||||
|
||||
// Verify match
|
||||
Console.WriteLine($"Match: {Math.Abs(batchLargeResult.Last().Value - lastStreamVal.Value) < 1e-10}");
|
||||
|
||||
#!markdown
|
||||
|
||||
## 3. TRIMA vs SMA Comparison
|
||||
|
||||
TRIMA is smoother than SMA but has more lag. Let's compare them on a volatile dataset.
|
||||
|
||||
#!csharp
|
||||
|
||||
Console.WriteLine("\n--- TRIMA vs SMA Comparison (Period 10) ---");
|
||||
|
||||
var compareData = new TSeries();
|
||||
var baseTime = DateTime.Now;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
// Create data with a sudden spike at position 10
|
||||
double value = (i == 10) ? 150.0 : 100.0;
|
||||
compareData.Add(baseTime.AddMinutes(i), value);
|
||||
}
|
||||
|
||||
var trimaCompare = new Trima(10);
|
||||
var smaCompare = new Sma(10);
|
||||
|
||||
Console.WriteLine("Position | Input | TRIMA | SMA | Difference");
|
||||
Console.WriteLine("---------+--------+---------+---------+-----------");
|
||||
|
||||
for (int i = 0; i < compareData.Count; i++)
|
||||
{
|
||||
var trimaVal = trimaCompare.Update(compareData[i]);
|
||||
var smaVal = smaCompare.Update(compareData[i]);
|
||||
var input = compareData[i].Value;
|
||||
var diff = trimaVal.Value - smaVal.Value;
|
||||
|
||||
Console.WriteLine($" {i,2} | {input,6:F0} | {trimaVal.Value,7:F2} | {smaVal.Value,7:F2} | {diff,+9:F2}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nNote how TRIMA reacts more gradually to the spike compared to SMA.");
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
public class TrimaIndicator : 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 Trima? ma;
|
||||
protected LineSeries? Series;
|
||||
protected string? SourceName;
|
||||
private int _warmupBarIndex = -1;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"TRIMA {Period}:{SourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/trima/Trima.Quantower.cs";
|
||||
|
||||
public TrimaIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
SourceName = Source.ToString();
|
||||
Name = "TRIMA - Triangular Moving Average";
|
||||
Description = "Triangular Moving Average";
|
||||
Series = new(name: $"TRIMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(Series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
ma = new Trima(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,197 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class TrimaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Trima_Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Trima(0));
|
||||
Assert.Throws<ArgumentException>(() => new Trima(-1));
|
||||
|
||||
var trima = new Trima(10);
|
||||
Assert.NotNull(trima);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_Calc_ReturnsValue()
|
||||
{
|
||||
var trima = new Trima(10);
|
||||
|
||||
Assert.Equal(0, trima.Value.Value);
|
||||
|
||||
TValue result = trima.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.True(result.Value > 0);
|
||||
Assert.Equal(result.Value, trima.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_CalculatesCorrectAverage_Period4()
|
||||
{
|
||||
// Period 4 -> weights [1, 2, 2, 1], sum 6
|
||||
var trima = new Trima(4);
|
||||
|
||||
trima.Update(new TValue(DateTime.UtcNow, 10));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 20));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 30));
|
||||
var r1 = trima.Update(new TValue(DateTime.UtcNow, 40));
|
||||
|
||||
// (1*10 + 2*20 + 2*30 + 1*40) / 6 = 150 / 6 = 25
|
||||
Assert.Equal(25.0, r1.Value, 1e-10);
|
||||
|
||||
var r2 = trima.Update(new TValue(DateTime.UtcNow, 50));
|
||||
// (1*20 + 2*30 + 2*40 + 1*50) / 6 = 210 / 6 = 35
|
||||
Assert.Equal(35.0, r2.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_CalculatesCorrectAverage_Period5()
|
||||
{
|
||||
// Period 5 -> weights [1, 2, 3, 2, 1], sum 9
|
||||
var trima = new Trima(5);
|
||||
|
||||
trima.Update(new TValue(DateTime.UtcNow, 10));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 20));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 30));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var r1 = trima.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// (1*10 + 2*20 + 3*30 + 2*40 + 1*50) / 9 = (10 + 40 + 90 + 80 + 50) / 9 = 270 / 9 = 30
|
||||
Assert.Equal(30.0, r1.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_IsHot_BecomesTrueWhenPeriodFilled()
|
||||
{
|
||||
var trima = new Trima(4);
|
||||
|
||||
Assert.False(trima.IsHot);
|
||||
trima.Update(new TValue(DateTime.UtcNow, 10)); // 1
|
||||
Assert.False(trima.IsHot);
|
||||
trima.Update(new TValue(DateTime.UtcNow, 20)); // 2
|
||||
Assert.False(trima.IsHot);
|
||||
trima.Update(new TValue(DateTime.UtcNow, 30)); // 3
|
||||
Assert.False(trima.IsHot);
|
||||
trima.Update(new TValue(DateTime.UtcNow, 40)); // 4
|
||||
Assert.True(trima.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_Update_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var trima = new Trima(4);
|
||||
|
||||
trima.Update(new TValue(DateTime.UtcNow, 10));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 20));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 30));
|
||||
|
||||
// Update with 40
|
||||
double val1 = trima.Update(new TValue(DateTime.UtcNow, 40), isNew: true).Value;
|
||||
// Expected: 25 (as calculated above)
|
||||
Assert.Equal(25.0, val1, 1e-10);
|
||||
|
||||
// Correct last value to 100 (was 40)
|
||||
// New window: 10, 20, 30, 100
|
||||
// Weights: 1, 2, 2, 1
|
||||
// (10 + 40 + 60 + 100) / 6 = 210 / 6 = 35
|
||||
double val2 = trima.Update(new TValue(DateTime.UtcNow, 100), isNew: false).Value;
|
||||
|
||||
Assert.Equal(35.0, val2, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_Reset_ClearsState()
|
||||
{
|
||||
var trima = new Trima(5);
|
||||
|
||||
trima.Update(new TValue(DateTime.UtcNow, 100));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 105));
|
||||
double valueBefore = trima.Value;
|
||||
|
||||
trima.Reset();
|
||||
|
||||
Assert.Equal(0, trima.Value.Value);
|
||||
Assert.False(trima.IsHot);
|
||||
|
||||
// After reset, should accept new values
|
||||
trima.Update(new TValue(DateTime.UtcNow, 50));
|
||||
Assert.NotEqual(0, trima.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var trima = new Trima(5);
|
||||
|
||||
trima.Update(new TValue(DateTime.UtcNow, 100));
|
||||
trima.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
// Feed NaN - should use last valid value (110)
|
||||
var resultAfterNaN = trima.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.True(double.IsFinite(resultAfterNaN.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var trimaIterative = new Trima(10);
|
||||
var trimaBatch = new Trima(10);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Calculate iteratively
|
||||
var iterativeResults = new TSeries();
|
||||
foreach (var item in series)
|
||||
{
|
||||
iterativeResults.Add(trimaIterative.Update(item));
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = trimaBatch.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);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trima_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 = Trima.Calculate(series, 10);
|
||||
|
||||
// Calculate with Span API
|
||||
Trima.Calculate(source.AsSpan(), output.AsSpan(), 10);
|
||||
|
||||
// Compare results
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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 TrimaValidationTests
|
||||
{
|
||||
private readonly TBarSeries _bars;
|
||||
private readonly TSeries _data;
|
||||
private readonly List<Quote> _skenderQuotes;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public TrimaValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
|
||||
// 1. Generate 1000 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));
|
||||
|
||||
// 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 TRIMA (batch TSeries)
|
||||
var trima = new global::QuanTAlib.Trima(period);
|
||||
var qResult = trima.Update(_data);
|
||||
|
||||
// Calculate Skender Composite TRIMA: SMA(SMA(x, p1), p2)
|
||||
int p1 = period / 2 + 1;
|
||||
int p2 = (period + 1) / 2;
|
||||
|
||||
var sma1Results = _skenderQuotes.GetSma(p1).ToList();
|
||||
|
||||
// Map SMA1 results to Quotes for the second pass
|
||||
// Note: We use 0 for null values during warmup, which might affect early values
|
||||
// but should stabilize for the verification window (last 100 records)
|
||||
var quotes2 = sma1Results.Select(r => new Quote
|
||||
{
|
||||
Date = r.Date,
|
||||
Close = (decimal)(r.Sma ?? 0)
|
||||
}).ToList();
|
||||
|
||||
var sResult = quotes2.GetSma(p2).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Skender(qResult, sResult);
|
||||
}
|
||||
_output.WriteLine("TRIMA Batch(TSeries) validated successfully against Skender Composite SMA");
|
||||
}
|
||||
|
||||
[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 TRIMA (batch TSeries)
|
||||
var trima = new global::QuanTAlib.Trima(period);
|
||||
var qResult = trima.Update(_data);
|
||||
|
||||
// Calculate TA-Lib TRIMA
|
||||
var retCode = TALib.Functions.Trima<double>(tData, 0..^0, output, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.TrimaLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Talib(qResult, output, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("TRIMA 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 TRIMA (batch TSeries)
|
||||
var trima = new global::QuanTAlib.Trima(period);
|
||||
var qResult = trima.Update(_data);
|
||||
|
||||
// Calculate Tulip TRIMA
|
||||
var trimaIndicator = Tulip.Indicators.trima;
|
||||
double[][] inputs = { tData };
|
||||
double[] options = { period };
|
||||
// Tulip TRIMA lookback might be different, let's calculate or infer
|
||||
// Usually it's period-1 for simple averages, but TRIMA is double smoothed.
|
||||
// We'll rely on the output length to align.
|
||||
// Tulip.Indicators.trima.Run expects outputs to be sized correctly.
|
||||
// We can try to run it with a large buffer and see what happens,
|
||||
// or calculate the expected lookback.
|
||||
// For TRIMA(n), lookback is roughly n-1.
|
||||
int lookback = period - 1;
|
||||
double[][] outputs = { new double[tData.Length - lookback] };
|
||||
|
||||
trimaIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Tulip(qResult, tResult, lookback);
|
||||
}
|
||||
_output.WriteLine("TRIMA 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 TRIMA (Span API)
|
||||
double[] qOutput = new double[sourceData.Length];
|
||||
global::QuanTAlib.Trima.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
|
||||
|
||||
// Calculate TA-Lib TRIMA
|
||||
var retCode = TALib.Functions.Trima<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.TrimaLookback(period);
|
||||
|
||||
// Compare last 100 records
|
||||
VerifyData_Talib_Span(qOutput, talibOutput, outRange, lookback);
|
||||
}
|
||||
_output.WriteLine("TRIMA Span validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
// ==================== Verification Helpers ====================
|
||||
|
||||
private static void VerifyData_Skender(TSeries qSeries, List<SmaResult> 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].Sma;
|
||||
|
||||
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,293 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// TRIMA: Triangular Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TRIMA is a weighted moving average where the weights increase linearly to the middle
|
||||
/// of the period and then decrease linearly. It places the most weight on the middle
|
||||
/// portion of the data series.
|
||||
///
|
||||
/// Calculation:
|
||||
/// TRIMA(period) = SMA(SMA(period1), period2)
|
||||
/// where:
|
||||
/// period1 = period / 2 + 1
|
||||
/// period2 = (period + 1) / 2
|
||||
///
|
||||
/// This implementation uses a flattened structure with two internal SMA buffers
|
||||
/// to ensure correct handling of warmup periods and bar corrections without
|
||||
/// the overhead of composed objects.
|
||||
///
|
||||
/// Key characteristics:
|
||||
/// - Smoother than SMA
|
||||
/// - Double smoothing (lag is higher than SMA)
|
||||
/// - Weights form a triangle
|
||||
/// - O(1) time complexity
|
||||
/// - O(period) space complexity
|
||||
///
|
||||
/// Sources:
|
||||
/// - https://www.investopedia.com/terms/t/triangularaverage.asp
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Trima
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly int _p1;
|
||||
private readonly int _p2;
|
||||
private readonly RingBuffer _buffer1;
|
||||
private readonly RingBuffer _buffer2;
|
||||
|
||||
// SMA1 State
|
||||
private double _sum1;
|
||||
private double _p_sum1;
|
||||
private double _p_lastInput1;
|
||||
private double _lastValidValue1;
|
||||
private double _p_lastValidValue1;
|
||||
private int _tickCount1;
|
||||
|
||||
// SMA2 State
|
||||
private double _sum2;
|
||||
private double _p_sum2;
|
||||
private double _p_lastInput2;
|
||||
private int _tickCount2;
|
||||
|
||||
private int _sampleCount;
|
||||
private const int ResyncInterval = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the indicator.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates TRIMA with specified period.
|
||||
/// </summary>
|
||||
/// <param name="period">Number of values to average (must be > 0)</param>
|
||||
public Trima(int period)
|
||||
{
|
||||
if (period <= 0)
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
|
||||
_period = period;
|
||||
_p1 = period / 2 + 1;
|
||||
_p2 = (period + 1) / 2;
|
||||
|
||||
_buffer1 = new RingBuffer(_p1);
|
||||
_buffer2 = new RingBuffer(_p2);
|
||||
|
||||
Name = $"Trima({period})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current TRIMA value.
|
||||
/// </summary>
|
||||
public TValue Value { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the TRIMA has enough data to produce valid results.
|
||||
/// </summary>
|
||||
public bool IsHot => _sampleCount >= _period;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a valid input value, using last-value substitution for non-finite inputs.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double GetValidValue(double input)
|
||||
{
|
||||
if (double.IsFinite(input))
|
||||
{
|
||||
_lastValidValue1 = input;
|
||||
return input;
|
||||
}
|
||||
return _lastValidValue1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates TRIMA with the given value.
|
||||
/// </summary>
|
||||
/// <param name="input">Input value</param>
|
||||
/// <param name="isNew">True for new bar, false for update to current bar (default: true)</param>
|
||||
/// <returns>Current TRIMA value</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_sampleCount++;
|
||||
|
||||
// SMA 1 Update
|
||||
double val1 = GetValidValue(input.Value);
|
||||
double removed1 = _buffer1.Count == _buffer1.Capacity ? _buffer1.Oldest : 0.0;
|
||||
_sum1 = _sum1 - removed1 + val1;
|
||||
_buffer1.Add(val1);
|
||||
|
||||
// Resync SMA1
|
||||
_tickCount1++;
|
||||
if (_buffer1.IsFull && _tickCount1 >= ResyncInterval)
|
||||
{
|
||||
_tickCount1 = 0;
|
||||
_sum1 = _buffer1.Sum();
|
||||
}
|
||||
|
||||
// Save SMA1 state
|
||||
_p_sum1 = _sum1;
|
||||
_p_lastInput1 = val1;
|
||||
_p_lastValidValue1 = _lastValidValue1;
|
||||
|
||||
// SMA 1 Result
|
||||
double sma1Result = _sum1 / _buffer1.Count;
|
||||
|
||||
// SMA 2 Update (Input is sma1Result)
|
||||
// Note: sma1Result is always finite if input stream has at least one finite value
|
||||
double removed2 = _buffer2.Count == _buffer2.Capacity ? _buffer2.Oldest : 0.0;
|
||||
_sum2 = _sum2 - removed2 + sma1Result;
|
||||
_buffer2.Add(sma1Result);
|
||||
|
||||
// Resync SMA2
|
||||
_tickCount2++;
|
||||
if (_buffer2.IsFull && _tickCount2 >= ResyncInterval)
|
||||
{
|
||||
_tickCount2 = 0;
|
||||
_sum2 = _buffer2.Sum();
|
||||
}
|
||||
|
||||
// Save SMA2 state
|
||||
_p_sum2 = _sum2;
|
||||
_p_lastInput2 = sma1Result;
|
||||
|
||||
// Final Result
|
||||
double trimaResult = _sum2 / _buffer2.Count;
|
||||
Value = new TValue(input.Time, trimaResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
// SMA 1 Correction
|
||||
_lastValidValue1 = _p_lastValidValue1;
|
||||
double val1 = GetValidValue(input.Value);
|
||||
_sum1 = _p_sum1 - _p_lastInput1 + val1;
|
||||
_buffer1.UpdateNewest(val1);
|
||||
|
||||
double sma1Result = _sum1 / _buffer1.Count;
|
||||
|
||||
// SMA 2 Correction
|
||||
_sum2 = _p_sum2 - _p_lastInput2 + sma1Result;
|
||||
_buffer2.UpdateNewest(sma1Result);
|
||||
|
||||
double trimaResult = _sum2 / _buffer2.Count;
|
||||
Value = new TValue(input.Time, trimaResult);
|
||||
}
|
||||
|
||||
return Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates TRIMA with the entire series.
|
||||
/// </summary>
|
||||
/// <param name="source">Input series</param>
|
||||
/// <returns>TRIMA series</returns>
|
||||
public TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0) return new TSeries(new List<long>(), new List<double>());
|
||||
|
||||
// Use the static Calculate method for performance
|
||||
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;
|
||||
|
||||
Calculate(sourceValues, vSpan, _period);
|
||||
sourceTimes.CopyTo(tSpan);
|
||||
|
||||
// Restore state by replaying the last part
|
||||
// We need to replay enough to fill both SMAs
|
||||
int lookback = _p1 + _p2;
|
||||
int startIndex = Math.Max(0, len - lookback);
|
||||
|
||||
// Reset internal state
|
||||
Reset();
|
||||
|
||||
// Replay
|
||||
for (int i = startIndex; i < len; i++)
|
||||
{
|
||||
Update(new TValue(sourceTimes[i], sourceValues[i]), isNew: true);
|
||||
}
|
||||
|
||||
Value = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates TRIMA for the entire series using a new instance.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, int period)
|
||||
{
|
||||
var trima = new Trima(period);
|
||||
return trima.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates TRIMA in-place.
|
||||
/// Uses ArrayPool to allocate temporary buffer and chains optimized SMA calculations.
|
||||
/// </summary>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
throw new ArgumentException("Source and output must have the same length");
|
||||
if (period <= 0)
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
|
||||
int p1 = period / 2 + 1;
|
||||
int p2 = (period + 1) / 2;
|
||||
|
||||
// Rent a temporary buffer for the intermediate SMA
|
||||
double[] tempArray = ArrayPool<double>.Shared.Rent(source.Length);
|
||||
Span<double> tempSpan = tempArray.AsSpan(0, source.Length);
|
||||
|
||||
try
|
||||
{
|
||||
// SMA 1
|
||||
Sma.Calculate(source, tempSpan, p1);
|
||||
|
||||
// SMA 2 (TRIMA)
|
||||
Sma.Calculate(tempSpan, output, p2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(tempArray);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the TRIMA state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_buffer1.Clear();
|
||||
_buffer2.Clear();
|
||||
|
||||
_sum1 = 0;
|
||||
_p_sum1 = 0;
|
||||
_p_lastInput1 = 0;
|
||||
_lastValidValue1 = 0;
|
||||
_p_lastValidValue1 = 0;
|
||||
_tickCount1 = 0;
|
||||
|
||||
_sum2 = 0;
|
||||
_p_sum2 = 0;
|
||||
_p_lastInput2 = 0;
|
||||
_tickCount2 = 0;
|
||||
|
||||
_sampleCount = 0;
|
||||
Value = default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# TRIMA: Triangular Moving Average
|
||||
|
||||
## Overview and Purpose
|
||||
|
||||
The Triangular Moving Average (TRIMA) is a technical indicator that applies a triangular weighting scheme to price data, providing enhanced smoothing compared to simpler moving averages. Originating in the early 1970s as technical analysts sought more effective noise filtering methods, the TRIMA was first popularized through the work of market technician Arthur Merrill. Its formal mathematical properties were established in the 1980s, and the indicator gained widespread adoption in the 1990s as computerized charting became standard. TRIMA effectively filters out market noise while maintaining important trends through its unique center-weighted calculation method.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
* **Double-smoothing process:** TRIMA can be viewed as applying a simple moving average twice, creating more effective noise filtering
|
||||
* **Triangular weighting:** Uses a symmetrical weight distribution that emphasizes central data points and reduces emphasis toward both ends
|
||||
* **Market application:** Particularly effective for identifying the underlying trend in noisy market conditions where standard moving averages generate too many false signals
|
||||
* **Timeframe flexibility:** Works across multiple timeframes, with longer periods providing cleaner trend signals in higher timeframes
|
||||
|
||||
The core innovation of TRIMA is its unique triangular weighting scheme, which can be viewed either as a specialized weight distribution or as a twice-applied simple moving average with adjusted period. This creates more effective noise filtering without the excessive lag penalty typically associated with longer-period averages. The symmetrical nature of the weight distribution ensures zero phase distortion, preserving the timing of important market turning points.
|
||||
|
||||
## Common Settings and Parameters
|
||||
|
||||
| Parameter | Default | Function | When to Adjust |
|
||||
|-----------|---------|----------|---------------|
|
||||
| Length | 14 | Controls the lookback period | Increase for smoother signals in volatile markets, decrease for responsiveness |
|
||||
| Source | close | Price data used for calculation | Consider using hlc3 for a more balanced price representation |
|
||||
|
||||
**Pro Tip:** For a good balance between smoothing and responsiveness, try using a TRIMA with period N instead of an SMA with period 2N - you'll get similar smoothing characteristics but with less lag.
|
||||
|
||||
## Calculation and Mathematical Foundation
|
||||
|
||||
**Simplified explanation:**
|
||||
TRIMA calculates a weighted average of prices where the weights form a triangle shape. The middle prices get the most weight, and weights gradually decrease toward both the recent and older ends. This creates a smooth filter that effectively removes random price fluctuations while preserving the underlying trend.
|
||||
|
||||
**Technical formula:**
|
||||
TRIMA = Σ(Price[i] × Weight[i]) / Σ(Weight[i])
|
||||
|
||||
Where the triangular weights form a symmetric pattern:
|
||||
|
||||
* Weight[i] = min(i, n-1-i) + 1
|
||||
* Example for n=5: weights = [1,2,3,2,1]
|
||||
* Example for n=4: weights = [1,2,2,1]
|
||||
|
||||
Alternatively, TRIMA can be calculated as:
|
||||
TRIMA(source, p) = SMA(SMA(source, (p+1)/2), (p+1)/2)
|
||||
|
||||
> 🔍 **Technical Note:** The double application of SMA explains why TRIMA provides better smoothing than a single SMA or WMA. This approach effectively applies smoothing twice with optimal period adjustment, creating a -18dB/octave roll-off in the frequency domain compared to -6dB/octave for a simple moving average.
|
||||
|
||||
## Interpretation Details
|
||||
|
||||
TRIMA can be used in various trading strategies:
|
||||
|
||||
* **Trend identification:** The direction of TRIMA indicates the prevailing trend
|
||||
* **Signal generation:** Crossovers between price and TRIMA generate trade signals with fewer false alarms than SMA
|
||||
* **Support/resistance levels:** TRIMA can act as dynamic support during uptrends and resistance during downtrends
|
||||
* **Trend strength assessment:** Distance between price and TRIMA can indicate trend strength
|
||||
* **Multiple timeframe analysis:** Using TRIMAs with different periods can confirm trends across different timeframes
|
||||
|
||||
## Limitations and Considerations
|
||||
|
||||
* **Market conditions:** Like all moving averages, less effective in choppy, sideways markets
|
||||
* **Lag factor:** More lag than WMA or EMA due to center-weighted emphasis
|
||||
* **Limited adaptability:** Fixed weighting scheme cannot adapt to changing market volatility
|
||||
* **Response time:** Takes longer to reflect sudden price changes than directionally-weighted averages
|
||||
* **Complementary tools:** Best used with momentum oscillators or volume indicators for confirmation
|
||||
|
||||
## References
|
||||
|
||||
* Ehlers, John F. "Cycle Analytics for Traders." Wiley, 2013
|
||||
* Kaufman, Perry J. "Trading Systems and Methods." Wiley, 2013
|
||||
* Colby, Robert W. "The Encyclopedia of Technical Market Indicators." McGraw-Hill, 2002
|
||||
@@ -0,0 +1,363 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class TrimaVectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Initialization_WithPeriods_Works()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
var res = trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
|
||||
Assert.Equal(3, res.Length);
|
||||
Assert.Equal(100.0, res[0].Value, 1e-9);
|
||||
Assert.Equal(100.0, res[1].Value, 1e-9);
|
||||
Assert.Equal(100.0, res[2].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialization_WithZeroPeriod_ThrowsArgumentException()
|
||||
{
|
||||
int[] periods = { 10, 0, 20 };
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new TrimaVector(periods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialization_WithNegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
int[] periods = { 10, -5, 20 };
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new TrimaVector(periods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_Streaming_MatchesSingleTrima()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
var trimaSingles = periods.Select(p => new Trima(p)).ToArray();
|
||||
|
||||
var values = new double[] { 10, 20, 30, 40, 50, 40, 30, 20, 10 };
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
foreach (var val in values)
|
||||
{
|
||||
var tVal = new TValue(time, val);
|
||||
var multiRes = trimaVector.Update(tVal);
|
||||
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
var singleRes = trimaSingles[i].Update(tVal);
|
||||
Assert.Equal(singleRes.Value, multiRes[i].Value, 1e-9);
|
||||
Assert.Equal(singleRes.Time, multiRes[i].Time);
|
||||
}
|
||||
|
||||
time = time.AddMinutes(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_Series_MatchesSingleTrima()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
int len = 100;
|
||||
var t = new System.Collections.Generic.List<long>(len);
|
||||
var v = new System.Collections.Generic.List<double>(len);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
t.Add(now.AddMinutes(i).Ticks);
|
||||
v.Add(Math.Sin(i * 0.1) * 100);
|
||||
}
|
||||
|
||||
var series = new TSeries(t, v);
|
||||
|
||||
var multiRes = trimaVector.Calculate(series);
|
||||
|
||||
// Reset and recalculate for comparison
|
||||
var trimaSingles = periods.Select(p => new Trima(p)).ToArray();
|
||||
for (int j = 0; j < len; j++)
|
||||
{
|
||||
var tVal = new TValue(new DateTime(t[j], DateTimeKind.Utc), v[j]);
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
var singleRes = trimaSingles[i].Update(tVal);
|
||||
Assert.Equal(singleRes.Value, multiRes[i].Values[j], 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_Series_MatchesStreaming()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
var trimaVectorBatch = new TrimaVector(periods);
|
||||
var trimaVectorStream = new TrimaVector(periods);
|
||||
|
||||
int len = 100;
|
||||
var t = new System.Collections.Generic.List<long>(len);
|
||||
var v = new System.Collections.Generic.List<double>(len);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
t.Add(now.AddMinutes(i).Ticks);
|
||||
v.Add(Math.Sin(i * 0.1) * 100);
|
||||
}
|
||||
|
||||
var series = new TSeries(t, v);
|
||||
|
||||
var batchRes = trimaVectorBatch.Calculate(series);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var tVal = new TValue(new DateTime(t[i], DateTimeKind.Utc), v[i]);
|
||||
var streamRes = trimaVectorStream.Update(tVal);
|
||||
|
||||
for (int j = 0; j < periods.Length; j++)
|
||||
{
|
||||
Assert.Equal(batchRes[j].Values[i], streamRes[j].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Static_MatchesInstanceMethod()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
|
||||
int len = 50;
|
||||
var t = new System.Collections.Generic.List<long>(len);
|
||||
var v = new System.Collections.Generic.List<double>(len);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
t.Add(now.AddMinutes(i).Ticks);
|
||||
v.Add(Math.Sin(i * 0.1) * 100);
|
||||
}
|
||||
|
||||
var series = new TSeries(t, v);
|
||||
|
||||
var instanceTrima = new TrimaVector(periods);
|
||||
var instanceRes = instanceTrima.Calculate(series);
|
||||
|
||||
var staticRes = TrimaVector.Calculate(series, periods);
|
||||
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
Assert.Equal(instanceRes[i].Count, staticRes[i].Count);
|
||||
for (int j = 0; j < len; j++)
|
||||
{
|
||||
Assert.Equal(instanceRes[i].Values[j], staticRes[i].Values[j], 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
int[] periods = { 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 200.0));
|
||||
trimaVector.Reset();
|
||||
|
||||
var res = trimaVector.Update(new TValue(DateTime.UtcNow, 50.0));
|
||||
|
||||
Assert.Equal(50.0, res[0].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
int[] periods = { 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 110.0));
|
||||
|
||||
var resultAfterNaN = trimaVector.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
foreach (var result in resultAfterNaN)
|
||||
{
|
||||
Assert.True(double.IsFinite(result.Value), $"Expected finite value but got {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
int[] periods = { 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 110.0));
|
||||
|
||||
var resultAfterPosInf = trimaVector.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
foreach (var result in resultAfterPosInf)
|
||||
{
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
var resultAfterNegInf = trimaVector.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
foreach (var result in resultAfterNegInf)
|
||||
{
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleNaN_ContinuesWithLastValid()
|
||||
{
|
||||
int[] periods = { 5, 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 110.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 120.0));
|
||||
|
||||
var r1 = trimaVector.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
var r2 = trimaVector.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
var r3 = trimaVector.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
foreach (var result in r1) Assert.True(double.IsFinite(result.Value));
|
||||
foreach (var result in r2) Assert.True(double.IsFinite(result.Value));
|
||||
foreach (var result in r3) Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Series_HandlesNaN()
|
||||
{
|
||||
int[] periods = { 5, 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
var t = new System.Collections.Generic.List<long>();
|
||||
var v = new System.Collections.Generic.List<double>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
t.Add(now.Ticks); v.Add(100.0);
|
||||
t.Add(now.AddMinutes(1).Ticks); v.Add(110.0);
|
||||
t.Add(now.AddMinutes(2).Ticks); v.Add(double.NaN);
|
||||
t.Add(now.AddMinutes(3).Ticks); v.Add(120.0);
|
||||
t.Add(now.AddMinutes(4).Ticks); v.Add(double.PositiveInfinity);
|
||||
t.Add(now.AddMinutes(5).Ticks); v.Add(130.0);
|
||||
|
||||
var series = new TSeries(t, v);
|
||||
var results = trimaVector.Calculate(series);
|
||||
|
||||
foreach (var periodResults in results)
|
||||
{
|
||||
foreach (var val in periodResults.Values)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsLastValidValue()
|
||||
{
|
||||
int[] periods = { 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
trimaVector.Reset();
|
||||
|
||||
var result = trimaVector.Update(new TValue(DateTime.UtcNow, 50.0));
|
||||
Assert.Equal(50.0, result[0].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Handling_MatchesSingleTrima()
|
||||
{
|
||||
int[] periods = { 5, 10, 20 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
var trimaSingles = periods.Select(p => new Trima(p)).ToArray();
|
||||
|
||||
var values = new double[] { 10, 20, double.NaN, 40, double.PositiveInfinity, 60, 70 };
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
foreach (var val in values)
|
||||
{
|
||||
var tVal = new TValue(time, val);
|
||||
var multiRes = trimaVector.Update(tVal);
|
||||
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
var singleRes = trimaSingles[i].Update(tVal);
|
||||
Assert.Equal(singleRes.Value, multiRes[i].Value, 1e-9);
|
||||
}
|
||||
|
||||
time = time.AddMinutes(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_Property_UpdatesAfterUpdate()
|
||||
{
|
||||
int[] periods = { 5, 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
var result = trimaVector.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
|
||||
Assert.Equal(result[0].Value, trimaVector.Values[0].Value);
|
||||
Assert.Equal(result[1].Value, trimaVector.Values[1].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_Property_UpdatesAfterCalculate()
|
||||
{
|
||||
int[] periods = { 5, 10 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
var t = new System.Collections.Generic.List<long> { 100, 200, 300 };
|
||||
var v = new System.Collections.Generic.List<double> { 10.0, 20.0, 30.0 };
|
||||
var series = new TSeries(t, v);
|
||||
|
||||
var results = trimaVector.Calculate(series);
|
||||
|
||||
Assert.Equal(results[0].Last.Value, trimaVector.Values[0].Value, 1e-9);
|
||||
Assert.Equal(results[1].Last.Value, trimaVector.Values[1].Value, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BarCorrection_WorksCorrectly()
|
||||
{
|
||||
int[] periods = { 3 };
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
|
||||
// TRIMA(3) = SMA(SMA(3, 2), 2)
|
||||
// p1 = 3/2 + 1 = 2
|
||||
// p2 = (3+1)/2 = 2
|
||||
// SMA1(2): 10 -> 10
|
||||
// SMA2(2): 10 -> 10
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true);
|
||||
|
||||
// SMA1(2): 10, 20 -> 15
|
||||
// SMA2(2): 10, 15 -> 12.5
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true);
|
||||
|
||||
// SMA1(2): 20, 30 -> 25
|
||||
// SMA2(2): 15, 25 -> 20
|
||||
trimaVector.Update(new TValue(DateTime.UtcNow, 30.0), isNew: true);
|
||||
|
||||
var res1 = trimaVector.Values[0].Value;
|
||||
Assert.Equal(20.0, res1, 1e-9);
|
||||
|
||||
// Correct the last bar: 30 -> 60
|
||||
// SMA1(2): 20, 60 -> 40
|
||||
// SMA2(2): 15, 40 -> 27.5
|
||||
var res2 = trimaVector.Update(new TValue(DateTime.UtcNow, 60.0), isNew: false);
|
||||
|
||||
Assert.Equal(27.5, res2[0].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-Period Triangular Moving Average (TRIMA) - SIMD optimized.
|
||||
/// Calculates multiple TRIMAs with different periods for the same input series in parallel.
|
||||
/// Uses last-value substitution for invalid inputs (NaN/Infinity).
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public class TrimaVector
|
||||
{
|
||||
private readonly SmaVector _sma1;
|
||||
private readonly int _count;
|
||||
private readonly TValue[] _values;
|
||||
|
||||
// Internal state for second stage
|
||||
private readonly RingBuffer[] _buffers2;
|
||||
private readonly RingBuffer[] _p_buffers2;
|
||||
private readonly double[] _lastValidValues2;
|
||||
|
||||
/// <summary>
|
||||
/// Current TRIMA values for all periods.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<TValue> Values => _values;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes TrimaVector with specified periods.
|
||||
/// </summary>
|
||||
/// <param name="periods">Array of periods (each must be > 0)</param>
|
||||
public TrimaVector(int[] periods)
|
||||
{
|
||||
_count = periods.Length;
|
||||
_values = new TValue[_count];
|
||||
_buffers2 = new RingBuffer[_count];
|
||||
_p_buffers2 = new RingBuffer[_count];
|
||||
_lastValidValues2 = new double[_count];
|
||||
|
||||
int[] p1 = new int[_count];
|
||||
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(periods[i], 0);
|
||||
p1[i] = periods[i] / 2 + 1;
|
||||
int p2 = (periods[i] + 1) / 2;
|
||||
|
||||
_buffers2[i] = new RingBuffer(p2);
|
||||
_p_buffers2[i] = new RingBuffer(p2);
|
||||
}
|
||||
|
||||
_sma1 = new SmaVector(p1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all TRIMA states.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_sma1.Reset();
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
_buffers2[i].Clear();
|
||||
_p_buffers2[i].Clear();
|
||||
}
|
||||
Array.Clear(_lastValidValues2);
|
||||
Array.Clear(_values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates TRIMAs with the given value.
|
||||
/// Uses last-value substitution: invalid inputs (NaN/Infinity) are replaced with
|
||||
/// the last known good value, providing continuity in the output series.
|
||||
/// </summary>
|
||||
/// <param name="input">Input value</param>
|
||||
/// <param name="isNew">True for new bar, false for update to current bar (default: true)</param>
|
||||
/// <returns>Array of TRIMA values</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue[] Update(TValue input, bool isNew = true)
|
||||
{
|
||||
// First pass: SMA1
|
||||
var sma1Results = _sma1.Update(input, isNew);
|
||||
|
||||
// Second pass: SMA2 (TRIMA)
|
||||
// We need to feed each SMA1 result into the corresponding SMA2
|
||||
// Since SmaVector.Update takes a single input, we can't use it directly for vector-to-vector
|
||||
// However, SmaVector is designed for single input -> multiple periods
|
||||
// Here we have multiple inputs (from SMA1) -> multiple periods (for SMA2)
|
||||
// This means we need to update each SMA2 individually, but SmaVector doesn't support that directly
|
||||
// Wait, SmaVector structure is: one input -> N periods.
|
||||
// Here we have N inputs (one for each period from SMA1) -> N periods (one for each period in SMA2).
|
||||
// So we can't use a single SmaVector for the second stage if the inputs are different.
|
||||
// We need N separate SMAs for the second stage, OR we need to modify SmaVector to support vector input.
|
||||
// But wait, TrimaVector is supposed to be optimized.
|
||||
// Let's look at how we can implement this efficiently.
|
||||
|
||||
// Actually, since each period in TRIMA maps to a specific pair of (p1, p2),
|
||||
// and the input to the second SMA depends on the output of the first SMA,
|
||||
// the inputs to the second stage are indeed all different.
|
||||
// So we can't use SmaVector for the second stage in the same way (single input broadcast to all).
|
||||
|
||||
// We have two options:
|
||||
// 1. Use an array of Sma objects for the second stage.
|
||||
// 2. Implement a custom vector-input SMA logic here.
|
||||
|
||||
// Given the goal of high performance and vectorization, option 2 is better but more complex.
|
||||
// However, for now, to match the structure and ensure correctness, let's use the fact that
|
||||
// we already have SmaVector which is optimized for ring buffers.
|
||||
// But SmaVector assumes a single input value for all buffers.
|
||||
// Here, _sma1 produces an array of values, one for each period.
|
||||
// _sma2 needs to take these DIFFERENT values.
|
||||
|
||||
// So, we cannot use SmaVector for the second stage if it only supports single input.
|
||||
// Let's check SmaVector again. Yes, Update takes `TValue input`.
|
||||
|
||||
// So we need to implement the second stage manually using RingBuffers, similar to SmaVector
|
||||
// but accepting a vector of inputs.
|
||||
|
||||
// Let's refactor:
|
||||
// Instead of using _sma2 as SmaVector, we'll manage the second stage buffers directly here.
|
||||
// This duplicates some logic from SmaVector but allows vector-to-vector processing.
|
||||
|
||||
// Actually, since we are implementing TrimaVector, maybe we should just use arrays of RingBuffers
|
||||
// for both stages directly, to avoid the mismatch.
|
||||
// But _sma1 is fine because it takes the single external input.
|
||||
// It's only the second stage that is problematic.
|
||||
|
||||
// Let's implement the second stage buffers directly.
|
||||
|
||||
// Wait, I can't change the class structure mid-method.
|
||||
// I will implement the class using _sma1 for the first stage, and manual buffers for the second stage.
|
||||
|
||||
// Re-reading my own thought process:
|
||||
// _sma1.Update(input) returns TValue[] with results for each period.
|
||||
// We need to feed result[i] into buffer2[i].
|
||||
|
||||
return UpdateInternal(sma1Results, isNew);
|
||||
}
|
||||
|
||||
private TValue[] UpdateInternal(TValue[] inputs, bool isNew)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
_p_buffers2[i].CopyFrom(_buffers2[i]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
_buffers2[i].CopyFrom(_p_buffers2[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
double val = inputs[i].Value;
|
||||
|
||||
// Last-value substitution for the second stage
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
_lastValidValues2[i] = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = _lastValidValues2[i];
|
||||
}
|
||||
|
||||
_buffers2[i].Add(val);
|
||||
_values[i] = new TValue(inputs[i].Time, _buffers2[i].Average);
|
||||
}
|
||||
|
||||
return _values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates TRIMAs for the entire series.
|
||||
/// </summary>
|
||||
/// <param name="source">Input series</param>
|
||||
/// <returns>Array of TRIMA series</returns>
|
||||
public TSeries[] Calculate(TSeries source)
|
||||
{
|
||||
// We can use the Update method for simplicity and correctness,
|
||||
// or implement a batch calculation for performance.
|
||||
// Given the complexity of double smoothing, using Update in a loop is safer and cleaner.
|
||||
// SmaVector.Calculate is optimized, but we have the two-stage issue.
|
||||
|
||||
// Let's use the Update loop approach for now to ensure correctness.
|
||||
// It will be reasonably fast.
|
||||
|
||||
int len = source.Count;
|
||||
var resultSeries = new TSeries[_count];
|
||||
|
||||
// Pre-allocate lists
|
||||
var tLists = new List<long>[_count];
|
||||
var vLists = new List<double>[_count];
|
||||
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
tLists[i] = new List<long>(len);
|
||||
vLists[i] = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(tLists[i], len);
|
||||
CollectionsMarshal.SetCount(vLists[i], len);
|
||||
}
|
||||
|
||||
Reset();
|
||||
|
||||
for (int t = 0; t < len; t++)
|
||||
{
|
||||
var tVal = new TValue(source.Times[t], source.Values[t]);
|
||||
var results = Update(tVal, isNew: true);
|
||||
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
CollectionsMarshal.AsSpan(tLists[i])[t] = results[i].Time;
|
||||
CollectionsMarshal.AsSpan(vLists[i])[t] = results[i].Value;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < _count; i++)
|
||||
{
|
||||
resultSeries[i] = new TSeries(tLists[i], vLists[i]);
|
||||
}
|
||||
|
||||
return resultSeries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates TRIMAs for the entire series using specified periods.
|
||||
/// </summary>
|
||||
/// <param name="source">Input series</param>
|
||||
/// <param name="periods">Array of periods</param>
|
||||
/// <returns>Array of TRIMA series</returns>
|
||||
public static TSeries[] Calculate(TSeries source, int[] periods)
|
||||
{
|
||||
var trimaVector = new TrimaVector(periods);
|
||||
return trimaVector.Calculate(source);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user