Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+172
View File
@@ -0,0 +1,172 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class PmaIndicatorTests
{
[Fact]
public void PmaIndicator_Constructor_SetsDefaults()
{
var indicator = new PmaIndicator();
Assert.Equal(7, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("PMA - Predictive Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PmaIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PmaIndicator();
Assert.Equal(0, PmaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void PmaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new PmaIndicator { Period = 14 };
Assert.Contains("PMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void PmaIndicator_SourceCodeLink_IsValid()
{
var indicator = new PmaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Pma.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void PmaIndicator_Initialize_CreatesInternalPma()
{
var indicator = new PmaIndicator { Period = 7 };
indicator.Initialize();
// After init, two line series should exist (PMA and Trigger)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void PmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.Equal(1, indicator.LinesSeries[1].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)));
}
[Fact]
public void PmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new PmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
Assert.Equal(2, indicator.LinesSeries[1].Count);
}
[Fact]
public void PmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new PmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double pmaFirst = indicator.LinesSeries[0].GetValue(0);
double trigFirst = indicator.LinesSeries[1].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double pmaSecond = indicator.LinesSeries[0].GetValue(0);
double trigSecond = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(pmaFirst));
Assert.True(double.IsFinite(trigFirst));
Assert.True(double.IsFinite(pmaSecond));
Assert.True(double.IsFinite(trigSecond));
}
[Fact]
public void PmaIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new PmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(closes.Length - 1 - i)));
}
double lastPma = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastPma >= 95 && lastPma <= 115);
}
[Fact]
public void PmaIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new PmaIndicator { Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite PMA value");
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)),
$"Source {source} should produce finite Trigger value");
}
}
[Fact]
public void PmaIndicator_Period_CanBeChanged()
{
var indicator = new PmaIndicator { Period = 7 };
Assert.Equal(7, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 7;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Pma _pma = null!;
private readonly LineSeries _series;
private readonly LineSeries _triggerSeries;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"PMA {Period}:{_sourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/pma/Pma.Quantower.cs";
public PmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
_sourceName = Source.ToString();
Name = "PMA - Predictive Moving Average";
Description = "Ehlers Predictive Moving Average";
_series = new LineSeries(name: $"PMA {Period}", color: Color.Yellow, width: 2, style: LineStyle.Solid);
_triggerSeries = new LineSeries(name: "Trigger", color: Color.Orange, width: 1, style: LineStyle.Solid);
AddLineSeries(_series);
AddLineSeries(_triggerSeries);
}
protected override void OnInit()
{
_pma = new Pma(Period);
_sourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
TValue result = _pma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
_series.SetValue(result.Value, _pma.IsHot, ShowColdValues);
_triggerSeries.SetValue(_pma.Trigger.Value, _pma.IsHot, ShowColdValues);
}
}
+366
View File
@@ -0,0 +1,366 @@
namespace QuanTAlib;
public class PmaTests
{
// === A) Constructor validation ===
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Pma(0));
Assert.Throws<ArgumentException>(() => new Pma(-1));
}
[Fact]
public void Constructor_ValidPeriod_SetsProperties()
{
var pma = new Pma(7);
Assert.Equal("Pma(7)", pma.Name);
Assert.Equal(13, pma.WarmupPeriod); // (7*2)-1
Assert.False(pma.IsHot);
}
// === B) Basic calculation ===
[Fact]
public void Update_ValidInput_CalculatesCorrectly()
{
// PMA(3) of [1, 2, 3, 4, 5]
// WMA(3): [1, 1.666, 2.333, 3.333, 4.333]
// WMA(WMA(3)): [1, 1.444, 1.888, 2.629, 3.518]
// PMA = 2*WMA1 - WMA2
// [1]: 2*1 - 1 = 1
// [2]: 2*1.666 - 1.444 = 1.888
// [3]: 2*2.333 - 1.888 = 2.777
var pma = new Pma(3);
var v1 = pma.Update(new TValue(DateTime.UtcNow, 1)).Value;
var v2 = pma.Update(new TValue(DateTime.UtcNow, 2)).Value;
var v3 = pma.Update(new TValue(DateTime.UtcNow, 3)).Value;
Assert.Equal(1.0, v1, 6);
Assert.Equal(1.888888, v2, 5);
Assert.Equal(2.777777, v3, 5);
}
[Fact]
public void Update_ReturnsCorrectTrigger()
{
// Trigger = (4*WMA1 - WMA2) / 3
// [1]: (4*1 - 1) / 3 = 1.0
// [2]: (4*1.666 - 1.444) / 3 = 5.222/3 = 1.740
// [3]: (4*2.333 - 1.888) / 3 = 7.444/3 = 2.481
var pma = new Pma(3);
pma.Update(new TValue(DateTime.UtcNow, 1));
double t1 = pma.Trigger.Value;
pma.Update(new TValue(DateTime.UtcNow, 2));
double t2 = pma.Trigger.Value;
pma.Update(new TValue(DateTime.UtcNow, 3));
double t3 = pma.Trigger.Value;
Assert.Equal(1.0, t1, 6);
Assert.Equal(1.740740, t2, 4);
Assert.Equal(2.481481, t3, 4);
}
[Fact]
public void Update_LastAndTriggerHaveTimestamps()
{
var pma = new Pma(3);
var time = DateTime.UtcNow;
pma.Update(new TValue(time, 100));
Assert.Equal(time.Ticks, pma.Last.Time);
Assert.Equal(time.Ticks, pma.Trigger.Time);
}
// === C) State + bar correction ===
[Fact]
public void Update_IsNewFalse_CorrectsValue()
{
var pma = new Pma(3);
pma.Update(new TValue(DateTime.UtcNow, 1));
pma.Update(new TValue(DateTime.UtcNow, 2));
var v3 = pma.Update(new TValue(DateTime.UtcNow, 3), isNew: true).Value;
var v3_corrected = pma.Update(new TValue(DateTime.UtcNow, 4), isNew: false).Value;
// Sequence [1, 2, 4]:
// WMA(3): [1, 1.666, 2.833]
// WMA(WMA(3)): [1, 1.444, 2.138]
// PMA: 2*2.833 - 2.138 = 3.527
Assert.Equal(2.777777, v3, 5);
Assert.Equal(3.527777, v3_corrected, 5);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var pma = new Pma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
TValue tenthInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
tenthInput = new TValue(bar.Time, bar.Close);
pma.Update(tenthInput, isNew: true);
}
double valueAfterTen = pma.Last.Value;
double triggerAfterTen = pma.Trigger.Value;
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
pma.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
TValue finalValue = pma.Update(tenthInput, isNew: false);
Assert.Equal(valueAfterTen, finalValue.Value, 1e-9);
Assert.Equal(triggerAfterTen, pma.Trigger.Value, 1e-9);
}
[Fact]
public void Reset_ClearsState()
{
var pma = new Pma(3);
pma.Update(new TValue(DateTime.UtcNow, 100));
pma.Update(new TValue(DateTime.UtcNow, 110));
pma.Reset();
Assert.False(pma.IsHot);
var v1 = pma.Update(new TValue(DateTime.UtcNow, 1)).Value;
Assert.Equal(1.0, v1);
}
// === D) Warmup/convergence ===
[Fact]
public void WarmupPeriod_AndIsHot_Agree()
{
int period = 5;
var pma = new Pma(period);
Assert.Equal(9, pma.WarmupPeriod); // (5*2)-1
for (int i = 0; i < 8; i++)
{
pma.Update(new TValue(DateTime.UtcNow, i + 1));
Assert.False(pma.IsHot, $"Should not be hot after {i + 1} samples");
}
pma.Update(new TValue(DateTime.UtcNow, 9));
Assert.True(pma.IsHot, "Should be hot after 9 samples (WarmupPeriod)");
}
// === E) Robustness ===
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var pma = new Pma(5);
pma.Update(new TValue(DateTime.UtcNow, 100));
pma.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterNaN = pma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.True(double.IsFinite(pma.Trigger.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var pma = new Pma(5);
pma.Update(new TValue(DateTime.UtcNow, 100));
pma.Update(new TValue(DateTime.UtcNow, 110));
var result = pma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
Assert.True(double.IsFinite(pma.Trigger.Value));
}
// === F) Consistency — Batch == Streaming == Span == Eventing ===
[Fact]
public void AllModes_ProduceSameResult()
{
int period = 7;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// 1. Batch Mode
var batchSeries = Pma.Batch(series, period);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray();
var spanOutput = new double[tValues.Length];
Pma.Batch(new ReadOnlySpan<double>(tValues), spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Pma(period);
for (int i = 0; i < series.Count; i++)
{
streamingInd.Update(series[i]);
}
double streamingResult = streamingInd.Last.Value;
// 4. Eventing Mode
var pubSource = new TSeries();
var eventingInd = new Pma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
// === G) Span API tests ===
[Fact]
public void SpanCalc_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
Assert.Throws<ArgumentException>(() => Pma.Batch(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Pma.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
}
[Fact]
public void SpanCalc_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Pma.Batch(source.AsSpan(), output.AsSpan(), 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val));
}
}
[Fact]
public void SpanCalc_DualOutput_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] pmaOut = new double[5];
double[] trigOut = new double[5];
double[] wrongSize = new double[3];
Assert.Throws<ArgumentException>(() => Pma.Batch(source.AsSpan(), wrongSize.AsSpan(), trigOut.AsSpan(), 3));
Assert.Throws<ArgumentException>(() => Pma.Batch(source.AsSpan(), pmaOut.AsSpan(), wrongSize.AsSpan(), 3));
Assert.Throws<ArgumentException>(() => Pma.Batch(source.AsSpan(), pmaOut.AsSpan(), trigOut.AsSpan(), 0));
}
[Fact]
public void SpanCalc_DualOutput_MatchesStreaming()
{
int period = 5;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 77);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
double[] src = series.Values.ToArray();
double[] pmaOut = new double[src.Length];
double[] trigOut = new double[src.Length];
Pma.Batch(src.AsSpan(), pmaOut.AsSpan(), trigOut.AsSpan(), period);
var streaming = new Pma(period);
for (int i = 0; i < src.Length; i++)
{
streaming.Update(series[i]);
}
Assert.Equal(streaming.Last.Value, pmaOut[^1], 1e-9);
Assert.Equal(streaming.Trigger.Value, trigOut[^1], 1e-9);
}
[Fact]
public void SpanCalc_LargeData_DoesNotStackOverflow()
{
int len = 5000;
double[] source = new double[len];
double[] output = new double[len];
for (int i = 0; i < len; i++)
{
source[i] = 100 + (i % 50);
}
Pma.Batch(source.AsSpan(), output.AsSpan(), 14);
Assert.True(double.IsFinite(output[^1]));
}
// === H) Chainability ===
[Fact]
public void Pub_Fires_OnUpdate()
{
var pma = new Pma(3);
int fireCount = 0;
pma.Pub += (object? sender, in TValueEventArgs args) => fireCount++;
pma.Update(new TValue(DateTime.UtcNow, 100));
pma.Update(new TValue(DateTime.UtcNow, 110));
Assert.Equal(2, fireCount);
}
[Fact]
public void EventBased_Chaining_Works()
{
var source = new TSeries();
var pma = new Pma(source, 5);
source.Add(new TValue(DateTime.UtcNow, 100));
source.Add(new TValue(DateTime.UtcNow, 110));
source.Add(new TValue(DateTime.UtcNow, 120));
Assert.True(double.IsFinite(pma.Last.Value));
Assert.True(double.IsFinite(pma.Trigger.Value));
}
[Fact]
public void StaticCalculate_MatchesInstance()
{
const int period = 10;
int count = 100;
var source = new TSeries();
var pma = new Pma(period);
for (int i = 0; i < count; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), i));
pma.Update(source.Last);
}
var staticResult = Pma.Batch(source, period);
Assert.Equal(source.Count, staticResult.Count);
Assert.Equal(pma.Last.Value, staticResult.Last.Value, 8);
}
}
+211
View File
@@ -0,0 +1,211 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class PmaValidationTests : IDisposable
{
private const int DefaultPeriod = 7;
private const double ValidationTolerance = 1e-9;
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
public PmaValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (disposing)
{
_testData.Dispose();
}
}
// PMA has no direct external library equivalent, so we validate
// by verifying PMA = 2*WMA - DWMA (component consistency)
[Fact]
public void Validate_PmaEqualsComponentFormula_Batch()
{
int period = DefaultPeriod;
var source = _testData.Data;
// Compute WMA and DWMA separately
var wmaResult = Wma.Batch(source, period);
var dwmaResult = Dwma.Batch(source, period);
// Compute PMA
var pmaResult = Pma.Batch(source, period);
// PMA = 2*WMA - DWMA
int count = pmaResult.Count;
int warmup = (period * 2) - 1;
for (int i = warmup; i < count; i++)
{
double expected = Math.FusedMultiplyAdd(2.0, wmaResult[i].Value, -dwmaResult[i].Value);
Assert.Equal(expected, pmaResult[i].Value, ValidationTolerance);
}
_output.WriteLine($"PMA({period}) component consistency: PASS ({count - warmup} bars validated at {ValidationTolerance})");
}
[Fact]
public void Validate_PmaEqualsComponentFormula_Streaming()
{
int period = DefaultPeriod;
var source = _testData.Data;
var wma = new Wma(period);
var dwma = new Dwma(period);
var pma = new Pma(period);
int warmup = (period * 2) - 1;
int validated = 0;
for (int i = 0; i < source.Count; i++)
{
var wmaVal = wma.Update(source[i]);
var dwmaVal = dwma.Update(source[i]);
var pmaVal = pma.Update(source[i]);
if (i >= warmup)
{
double expected = Math.FusedMultiplyAdd(2.0, wmaVal.Value, -dwmaVal.Value);
Assert.Equal(expected, pmaVal.Value, ValidationTolerance);
validated++;
}
}
_output.WriteLine($"PMA({period}) streaming component consistency: PASS ({validated} bars validated)");
}
[Fact]
public void Validate_PmaEqualsComponentFormula_Span()
{
int period = DefaultPeriod;
var rawData = _testData.RawData;
var wmaOutput = new double[rawData.Length];
var dwmaOutput = new double[rawData.Length];
var pmaOutput = new double[rawData.Length];
Wma.Batch(rawData.Span, wmaOutput.AsSpan(), period);
Dwma.Batch(rawData.Span, dwmaOutput.AsSpan(), period);
Pma.Batch(rawData.Span, pmaOutput.AsSpan(), period);
int warmup = (period * 2) - 1;
int validated = 0;
for (int i = warmup; i < rawData.Length; i++)
{
double expected = Math.FusedMultiplyAdd(2.0, wmaOutput[i], -dwmaOutput[i]);
Assert.Equal(expected, pmaOutput[i], ValidationTolerance);
validated++;
}
_output.WriteLine($"PMA({period}) span component consistency: PASS ({validated} bars validated)");
}
[Fact]
public void Validate_TriggerEqualsComponentFormula_Streaming()
{
int period = DefaultPeriod;
var source = _testData.Data;
var wma = new Wma(period);
var dwma = new Dwma(period);
var pma = new Pma(period);
int warmup = (period * 2) - 1;
int validated = 0;
for (int i = 0; i < source.Count; i++)
{
var wmaVal = wma.Update(source[i]);
var dwmaVal = dwma.Update(source[i]);
_ = pma.Update(source[i]);
if (i >= warmup)
{
// Trigger = (4*WMA - DWMA) / 3
double expected = Math.FusedMultiplyAdd(4.0, wmaVal.Value, -dwmaVal.Value) / 3.0;
Assert.Equal(expected, pma.Trigger.Value, ValidationTolerance);
validated++;
}
}
_output.WriteLine($"PMA({period}) trigger component consistency: PASS ({validated} bars validated)");
}
[Fact]
public void Validate_TriggerEqualsComponentFormula_Span()
{
int period = DefaultPeriod;
var rawData = _testData.RawData;
var wmaOutput = new double[rawData.Length];
var dwmaOutput = new double[rawData.Length];
var pmaOutput = new double[rawData.Length];
var triggerOutput = new double[rawData.Length];
Wma.Batch(rawData.Span, wmaOutput.AsSpan(), period);
Dwma.Batch(rawData.Span, dwmaOutput.AsSpan(), period);
Pma.Batch(rawData.Span, pmaOutput.AsSpan(), triggerOutput.AsSpan(), period);
int warmup = (period * 2) - 1;
int validated = 0;
for (int i = warmup; i < rawData.Length; i++)
{
double expected = Math.FusedMultiplyAdd(4.0, wmaOutput[i], -dwmaOutput[i]) / 3.0;
Assert.Equal(expected, triggerOutput[i], ValidationTolerance);
validated++;
}
_output.WriteLine($"PMA({period}) trigger span consistency: PASS ({validated} bars validated)");
}
[Fact]
public void Validate_BatchMatchesStreaming()
{
int period = DefaultPeriod;
var source = _testData.Data;
var batchResult = Pma.Batch(source, period);
var pma = new Pma(period);
for (int i = 0; i < source.Count; i++)
{
pma.Update(source[i]);
}
Assert.Equal(batchResult.Last.Value, pma.Last.Value, ValidationTolerance);
_output.WriteLine($"PMA({period}) batch-streaming equivalence: PASS");
}
[Fact]
public void Validate_SpanMatchesStreaming()
{
int period = DefaultPeriod;
var rawData = _testData.RawData;
var spanOutput = new double[rawData.Length];
Pma.Batch(rawData.Span, spanOutput.AsSpan(), period);
var pma = new Pma(period);
for (int i = 0; i < rawData.Length; i++)
{
pma.Update(new TValue(DateTime.MinValue, rawData.Span[i]));
}
Assert.Equal(pma.Last.Value, spanOutput[^1], ValidationTolerance);
_output.WriteLine($"PMA({period}) span-streaming equivalence: PASS");
}
}
+289
View File
@@ -0,0 +1,289 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// PMA: Predictive Moving Average
/// </summary>
/// <remarks>
/// Ehlers' linear-extrapolation filter using dual WMA cascade.
/// Cancels one WMA lag via extrapolation; Trigger line provides crossover signals.
///
/// Calculation: <c>PMA = 2×WMA(src) WMA(WMA(src))</c>, <c>Trigger = (4×WMA(src) WMA(WMA(src))) / 3</c>.
/// O(1) per bar via composed Wma instances.
/// </remarks>
/// <seealso href="Pma.md">Detailed documentation</seealso>
/// <seealso href="pma.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Pma : AbstractBase
{
private readonly int _period;
private readonly Wma _wma1;
private readonly Wma _wma2;
private readonly ITValuePublisher? _source;
private readonly TValuePublishedHandler? _handler;
private bool _disposed;
private int _sampleCount;
/// <summary>
/// The Trigger (signal) line value: (4×WMA WMA(WMA)) / 3.
/// </summary>
public TValue Trigger { get; private set; }
public override bool IsHot => _sampleCount >= WarmupPeriod;
/// <summary>
/// Creates PMA with specified period.
/// </summary>
/// <param name="period">Window size for WMA smoothing (must be > 0, Ehlers default: 7)</param>
public Pma(int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_wma1 = new Wma(period);
_wma2 = new Wma(period);
Name = $"Pma({period})";
WarmupPeriod = (period * 2) - 1;
}
/// <summary>
/// Creates PMA subscribed to a source publisher.
/// </summary>
public Pma(ITValuePublisher source, int period) : this(period)
{
_source = source;
_handler = Handle;
source.Pub += _handler;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null && _handler != null)
{
_source.Pub -= _handler;
}
_disposed = true;
}
base.Dispose(disposing);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_sampleCount++;
}
TValue wma1Result = _wma1.Update(input, isNew);
TValue wma2Result = _wma2.Update(wma1Result, isNew);
double w1 = wma1Result.Value;
double w2 = wma2Result.Value;
// PMA = 2×WMA(src) WMA(WMA(src))
double pma = Math.FusedMultiplyAdd(2.0, w1, -w2);
// Trigger = (4×WMA(src) WMA(WMA(src))) / 3
double trigger = Math.FusedMultiplyAdd(4.0, w1, -w2) / 3.0;
Last = new TValue(input.Time, pma);
Trigger = new TValue(input.Time, trigger);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
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);
Batch(source.Values, vSpan, _period);
Reset();
int lookback = WarmupPeriod + 10;
int startIndex = Math.Max(0, len - lookback);
for (int i = startIndex; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]));
}
_sampleCount = len;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
private void Handle(object? sender, in TValueEventArgs args)
{
Update(args.Value, args.IsNew);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
Reset();
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
public static TSeries Batch(TSeries source, int period)
{
var pma = new Pma(period);
return pma.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
int len = source.Length;
if (len == 0)
{
return;
}
double[]? wma1Array = len > 1024 ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> wma1 = len <= 1024
? stackalloc double[len]
: wma1Array!.AsSpan(0, len);
double[]? wma2Array = len > 1024 ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> wma2 = len <= 1024
? stackalloc double[len]
: wma2Array!.AsSpan(0, len);
try
{
Wma.Batch(source, wma1, period);
Wma.Batch(wma1, wma2, period);
// PMA = 2×WMA1 WMA2
for (int i = 0; i < len; i++)
{
output[i] = Math.FusedMultiplyAdd(2.0, wma1[i], -wma2[i]);
}
}
finally
{
if (wma1Array != null)
{
ArrayPool<double>.Shared.Return(wma1Array);
}
if (wma2Array != null)
{
ArrayPool<double>.Shared.Return(wma2Array);
}
}
}
/// <summary>
/// Span-based batch returning both PMA and Trigger lines.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> pmaOutput, Span<double> triggerOutput, int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (source.Length != pmaOutput.Length)
{
throw new ArgumentException("Source and pmaOutput must have the same length", nameof(pmaOutput));
}
if (source.Length != triggerOutput.Length)
{
throw new ArgumentException("Source and triggerOutput must have the same length", nameof(triggerOutput));
}
int len = source.Length;
if (len == 0)
{
return;
}
double[]? wma1Array = len > 1024 ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> wma1 = len <= 1024
? stackalloc double[len]
: wma1Array!.AsSpan(0, len);
double[]? wma2Array = len > 1024 ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> wma2 = len <= 1024
? stackalloc double[len]
: wma2Array!.AsSpan(0, len);
try
{
Wma.Batch(source, wma1, period);
Wma.Batch(wma1, wma2, period);
for (int i = 0; i < len; i++)
{
double w1 = wma1[i];
double w2 = wma2[i];
pmaOutput[i] = Math.FusedMultiplyAdd(2.0, w1, -w2);
triggerOutput[i] = Math.FusedMultiplyAdd(4.0, w1, -w2) / 3.0;
}
}
finally
{
if (wma1Array != null)
{
ArrayPool<double>.Shared.Return(wma1Array);
}
if (wma2Array != null)
{
ArrayPool<double>.Shared.Return(wma2Array);
}
}
}
public static (TSeries Results, Pma Indicator) Calculate(TSeries source, int period)
{
var indicator = new Pma(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_wma1.Reset();
_wma2.Reset();
_sampleCount = 0;
Last = default;
Trigger = default;
}
}
+151
View File
@@ -0,0 +1,151 @@
# PMA: Predictive Moving Average
> "John Ehlers looked at WMA's lag and said: 'What if we just extrapolated it away?' The result is a moving average that actually tries to predict where price is going, not where it has been."
PMA (Predictive Moving Average) is a lag-cancellation filter that uses linear extrapolation of dual WMA (Weighted Moving Average) cascades to predict price direction. It produces two outputs: the PMA line (extrapolated trend) and a Trigger line for crossover signals. Default period is 7 per Ehlers' original specification.
## Historical Context
John F. Ehlers introduced the Predictive Moving Average in his 2004 work on cycle-based indicators. The core insight is borrowed from signal processing: if you know how much a filter lags, you can extrapolate forward by that amount to cancel the lag entirely.
WMA inherently lags by approximately $(N-1)/3$ bars for period $N$. A WMA of a WMA (what we call DWMA) lags by roughly $2(N-1)/3$. The difference between WMA and DWMA captures the rate of lag accumulation, which Ehlers uses as a linear extrapolation coefficient.
This is not the same trick as Hull Moving Average (HMA), which uses WMA of period $N/2$ minus WMA of period $N$. PMA uses the same period for both WMA passes, which makes the extrapolation purely about lag cancellation rather than period blending. The distinction matters: PMA's extrapolation is geometrically cleaner, though HMA's period-blending produces less overshoot in choppy markets.
Prior art includes DEMA (Double EMA, Patrick Mulloy 1994) which uses the same $2 \times \text{MA} - \text{MA(MA)}$ formula but with EMA instead of WMA. The WMA variant has slightly different frequency response characteristics due to WMA's finite impulse response (FIR) nature versus EMA's infinite impulse response (IIR).
## Architecture and Physics
### 1. Dual WMA Cascade
Two WMA instances with identical period $N$ are chained: the first processes raw price, the second processes the output of the first.
```text
src --> WMA1(N) --> wma1
wma1 --> WMA2(N) --> wma2
```
Each WMA operates in O(1) per bar via dual running sums (simple sum and weighted sum), using a ring buffer of size $N$.
### 2. Linear Extrapolation (PMA Line)
The PMA line cancels one full WMA lag by extrapolating:
$$\text{PMA}_t = 2 \times \text{WMA}_t - \text{WMA}(\text{WMA})_t$$
This works because $\text{WMA}(\text{WMA})$ lags approximately twice as much as $\text{WMA}$ alone. The formula $2A - B$ where $B$ lags twice as much as $A$ is a first-order Richardson extrapolation, a standard numerical technique for cancelling leading error terms.
### 3. Trigger Line
The Trigger line is a weighted blend designed for crossover signals:
$$\text{Trigger}_t = \frac{4 \times \text{WMA}_t - \text{WMA}(\text{WMA})_t}{3}$$
The Trigger line sits between WMA and PMA in terms of responsiveness. It converges toward PMA faster than raw WMA but with less overshoot. Crossovers between PMA and Trigger provide timing signals: PMA crossing above Trigger is bullish, below is bearish.
### 4. Composition Architecture
Rather than reimplementing WMA ring buffer logic, the implementation composes two existing `Wma` instances. This follows the DRY principle and inherits all WMA optimizations (SIMD batch processing, NaN handling, resync drift correction) automatically.
Bar correction (`isNew=false`) is forwarded to both internal WMA instances, ensuring state rollback propagates correctly through the cascade.
## Mathematical Foundation
### WMA Recurrence
For period $N$ with weights $w_i = i$:
$$\text{WMA}_t = \frac{\sum_{i=1}^{N} i \cdot P_{t-N+i}}{\sum_{i=1}^{N} i} = \frac{\sum_{i=1}^{N} i \cdot P_{t-N+i}}{N(N+1)/2}$$
### O(1) Update
When the buffer is full, adding value $v_{\text{new}}$ and dropping $v_{\text{old}}$:
$$S_{t} = S_{t-1} - v_{\text{old}} + v_{\text{new}}$$
$$W_{t} = W_{t-1} - S_{t-1} + N \cdot v_{\text{new}}$$
$$\text{WMA}_t = \frac{W_t}{N(N+1)/2}$$
### PMA Derivation
Let $L_1 \approx (N-1)/3$ be the WMA lag and $L_2 \approx 2(N-1)/3$ be the DWMA lag.
The extrapolation:
$$\text{PMA} = 2 \cdot \text{WMA} - \text{DWMA}$$
effectively computes: $P_{t} + (P_{t} - P_{t-\Delta}) = 2P_t - P_{t-\Delta}$ where $\Delta$ is the lag difference. This is a first-order Taylor expansion of the price function.
### Trigger Derivation
$$\text{Trigger} = \frac{4 \cdot \text{WMA} - \text{DWMA}}{3}$$
This is a weighted interpolation between WMA (weight 4/3) and DWMA (weight -1/3), producing a line with approximately 2/3 of PMA's lag cancellation.
### Warmup Period
Since two WMA passes of period $N$ are cascaded:
$$\text{Warmup} = 2N - 1$$
The second WMA requires $N$ bars of valid input from the first WMA, which itself requires $N$ bars.
## Performance Profile
| Metric | Value |
|--------|-------|
| Update complexity | O(1) per bar |
| Batch complexity | O(n) with SIMD acceleration |
| Memory | 2 ring buffers of size $N$ |
| Allocations per Update | 0 (zero-allocation hot path) |
| SIMD support | Inherited from WMA (AVX-512/AVX2/NEON) |
| FMA usage | Yes, in extrapolation formulas |
### Quality Metrics (1-10 scale)
| Metric | Score | Notes |
|--------|-------|-------|
| Lag reduction | 9 | Near-zero lag for trending markets |
| Noise rejection | 5 | Extrapolation amplifies noise |
| Overshoot | 4 | Will overshoot in choppy conditions |
| Trend detection | 8 | Excellent with Trigger crossovers |
| Whipsaw resistance | 4 | Low; use with trend confirmation |
| Computational cost | 9 | O(1) composed from existing WMA |
## Validation
PMA has no direct equivalent in external libraries. Validation uses component consistency: verify that `PMA = 2*WMA - DWMA` and `Trigger = (4*WMA - DWMA) / 3` hold exactly for all bars.
| Test | Method | Tolerance | Result |
|------|--------|-----------|--------|
| Component consistency (batch) | PMA vs 2*WMA-DWMA | 1e-9 | PASS |
| Component consistency (streaming) | Same, bar-by-bar | 1e-9 | PASS |
| Component consistency (span) | Same, span API | 1e-9 | PASS |
| Trigger consistency (streaming) | Trigger vs (4*WMA-DWMA)/3 | 1e-9 | PASS |
| Trigger consistency (span) | Same, span API | 1e-9 | PASS |
| Batch-streaming equivalence | Last values match | 1e-9 | PASS |
| Span-streaming equivalence | Last values match | 1e-9 | PASS |
## Common Pitfalls
1. **Overshoot in choppy markets.** PMA extrapolates the trend; in ranging conditions, it will overshoot reversals. Impact: false signals increase 30-50% versus raw WMA. Mitigation: use Trigger crossovers, not PMA direction alone.
2. **Not a predictive oracle.** The name "Predictive" refers to lag cancellation via extrapolation, not forecasting. PMA predicts where a lagged average *should* be, not where price *will* be.
3. **Noise amplification.** The $2 \times \text{WMA} - \text{DWMA}$ formula doubles the noise component of WMA while only partially cancelling DWMA's smoothing. For noisy data, increase the period or pre-filter the input.
4. **Warmup is $2N-1$, not $N$.** Two cascaded WMA passes require $2N-1$ bars before the output is fully formed. Using PMA output before warmup completes will show convergence artifacts.
5. **Trigger is not a simple moving average of PMA.** The Trigger line is computed from the same WMA components as PMA, not from PMA output. This means Trigger does not lag PMA by a fixed amount; the relationship varies with market conditions.
6. **Bar correction must propagate.** When `isNew=false`, both internal WMA instances must roll back state. The composition architecture handles this automatically, but manual reimplementations often miss the second WMA rollback.
7. **Period 1 degenerates.** With period 1, WMA equals the input, DWMA equals the input, and PMA equals the input. The indicator provides no smoothing or prediction. Minimum useful period is 3.
## References
- Ehlers, J.F. (2004). *Cybernetic Analysis for Stocks and Futures*. John Wiley and Sons.
- Ehlers, J.F. (2001). "MESA Adaptive Moving Average." *Technical Analysis of Stocks and Commodities*, September 2001.
- Mulloy, P.G. (1994). "Smoothing Data with Faster Moving Averages." *Technical Analysis of Stocks and Commodities*, February 1994.
- Richardson, L.F. (1911). "The Approximate Arithmetical Solution by Finite Differences of Physical Problems." *Philosophical Transactions of the Royal Society A*, 210: 307-357.
+91
View File
@@ -0,0 +1,91 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
// Indicator algorithm (C) 2004-2024 John F. Ehlers
indicator("Ehlers Predictive Moving Average (PMA)", "PMA", overlay=true)
//@function Calculates Ehlers Predictive Moving Average using WMA-based linear extrapolation
//@param source Series to calculate PMA from
//@param period Lookback period for WMA smoothing (>= 1, default 7 per Ehlers)
//@returns [pma, trigger] where PMA = 2×WMA WMA(WMA) and Trigger = (4×WMA WMA(WMA)) / 3
//@optimized Uses dual running sums with cached denominator for O(1) WMA complexity per bar
pma(series float source, simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
// --- First WMA: WMA(source, period) --- matches canonical wma.pine pattern
var array<float> buffer1 = array.new_float(period, na)
var int head1 = 0
var float sum1 = 0.0
var float weighted_sum1 = 0.0
var int count1 = 0
var float norm1 = 0.0
float oldest1 = array.get(buffer1, head1)
float current1 = nz(source)
if not na(oldest1)
float old_sum1 = sum1
sum1 -= oldest1
sum1 += current1
weighted_sum1 := weighted_sum1 - old_sum1 + (period * current1)
else
count1 += 1
sum1 += current1
weighted_sum1 := weighted_sum1 + (count1 * current1)
norm1 := count1 * (count1 + 1) * 0.5
array.set(buffer1, head1, current1)
head1 := (head1 + 1) % period
float wma1 = weighted_sum1 / norm1
// --- Second WMA: WMA(WMA1, period) --- same O(1) circular buffer on first WMA output
var array<float> buffer2 = array.new_float(period, na)
var int head2 = 0
var float sum2 = 0.0
var float weighted_sum2 = 0.0
var int count2 = 0
var float norm2 = 0.0
float oldest2 = array.get(buffer2, head2)
float current2 = nz(wma1)
if not na(oldest2)
float old_sum2 = sum2
sum2 -= oldest2
sum2 += current2
weighted_sum2 := weighted_sum2 - old_sum2 + (period * current2)
else
count2 += 1
sum2 += current2
weighted_sum2 := weighted_sum2 + (count2 * current2)
norm2 := count2 * (count2 + 1) * 0.5
array.set(buffer2, head2, current2)
head2 := (head2 + 1) % period
float wma2 = weighted_sum2 / norm2
// Predictive line: cancels one WMA lag via linear extrapolation
// PMA = 2 × WMA(src) WMA(WMA(src))
float pma_val = 2.0 * wma1 - wma2
// Trigger/signal line: weighted blend for crossover signals
// Trigger = (4 × WMA(src) WMA(WMA(src))) / 3
float trigger_val = (4.0 * wma1 - wma2) / 3.0
[na(source) ? na : pma_val, na(source) ? na : trigger_val]
// ---------- Main loop ----------
// Inputs
i_period = input.int(7, "Period", minval=1, tooltip="Lookback period for WMA smoothing (Ehlers default: 7)")
i_source = input.source(close, "Source")
// Calculation
[pma_value, trigger_value] = pma(i_source, i_period)
// Plot
plot(pma_value, "PMA", color=color.yellow, linewidth=2)
plot(trigger_value, "Trigger", color=color.orange, linewidth=1)