Implement Jurik Moving Average (JMA) with adaptive smoothing and comprehensive tests

This commit is contained in:
Miha Kralj
2025-12-12 21:35:00 -08:00
parent 79dcbcaddd
commit 60227a23c1
7 changed files with 1012 additions and 1 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ Trend indicators help identify the direction and strength of a market trend. Mov
| HPF | Ehlers Highpass Filter | |
| HTIT | Hilbert Transform Instantaneous Trend | |
| HWMA | Holt Weighted MA | |
| JMA | Jurik MA | |
| [JMA](trends/jma/Jma.md) | Jurik MA | Adaptive moving average that adjusts to market volatility for superior smoothing with minimal lag. |
| [KAMA](trends/kama/Kama.md) | Kaufman Adaptive MA | Adapts to market volatility by adjusting its smoothing factor based on an Efficiency Ratio. |
| KF | Kalman Filter | |
| LOESS | LOESS/LOWESS Smoothing | |
+188
View File
@@ -0,0 +1,188 @@
using Xunit;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class JmaIndicatorTests
{
[Fact]
public void JmaIndicator_Constructor_SetsDefaults()
{
var indicator = new JmaIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(0, indicator.Phase);
Assert.Equal(0.45, indicator.Power);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("JMA - Jurik Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void JmaIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new JmaIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void JmaIndicator_ShortName_IncludesParameters()
{
var indicator = new JmaIndicator { Period = 15, Phase = 50, Power = 0.8 };
Assert.Contains("JMA", indicator.ShortName);
Assert.Contains("15", indicator.ShortName);
Assert.Contains("50", indicator.ShortName);
Assert.Contains("0.8", indicator.ShortName);
}
[Fact]
public void JmaIndicator_SourceCodeLink_IsValid()
{
var indicator = new JmaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Jma.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void JmaIndicator_Initialize_CreatesInternalJma()
{
var indicator = new JmaIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void JmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new JmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void JmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new JmaIndicator { 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);
}
[Fact]
public void JmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new JmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void JmaIndicator_OnPaintChart_DoesNotThrow()
{
var indicator = new JmaIndicator();
indicator.Initialize();
var method = indicator.GetType().GetMethod("OnPaintChart");
Assert.NotNull(method);
Assert.Equal(typeof(JmaIndicator), method.DeclaringType);
}
[Fact]
public void JmaIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new JmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
}
[Fact]
public void JmaIndicator_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 JmaIndicator { Period = 3, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void JmaIndicator_Parameters_CanBeChanged()
{
var indicator = new JmaIndicator { Period = 5, Phase = 10, Power = 0.5 };
Assert.Equal(5, indicator.Period);
Assert.Equal(10, indicator.Phase);
Assert.Equal(0.5, indicator.Power);
indicator.Period = 20;
indicator.Phase = -10;
indicator.Power = 0.9;
Assert.Equal(20, indicator.Period);
Assert.Equal(-10, indicator.Phase);
Assert.Equal(0.9, indicator.Power);
Assert.Equal(20, indicator.MinHistoryDepths);
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class JmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[InputParameter("Phase", sortIndex: 2, -100, 100, 1, 0)]
public int Phase { get; set; } = 0;
[InputParameter("Power", sortIndex: 3, 0.1, 10.0, 0.1, 1)]
public double Power { get; set; } = 0.45;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Jma? ma;
protected LineSeries? Series;
protected string? SourceName;
private int _warmupBarIndex = -1;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"JMA {Period}:{Phase}:{Power}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/jma/Jma.Quantower.cs";
public JmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "JMA - Jurik Moving Average";
Description = "Jurik Moving Average";
Series = new(name: $"JMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Jma(Period, Phase, Power);
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);
}
}
+239
View File
@@ -0,0 +1,239 @@
using System;
using System.Linq;
using Xunit;
namespace QuanTAlib.Tests;
public class JmaTests
{
[Fact]
public void Jma_Constructor_ValidatesInput()
{
// JMA doesn't explicitly throw on period currently, but let's check if it handles valid inputs
var jma = new Jma(10);
Assert.NotNull(jma);
}
[Fact]
public void Jma_Calc_ReturnsValue()
{
var jma = new Jma(10);
Assert.Equal(0, jma.Last.Value);
TValue result = jma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0);
Assert.Equal(result.Value, jma.Last.Value);
}
[Fact]
public void Jma_Calc_IsNew_AcceptsParameter()
{
var jma = new Jma(10);
jma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = jma.Last.Value;
jma.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
double value2 = jma.Last.Value;
// Values should change with new bars
Assert.NotEqual(value1, value2);
}
[Fact]
public void Jma_Calc_IsNew_False_UpdatesValue()
{
var jma = new Jma(10);
jma.Update(new TValue(DateTime.UtcNow, 100));
jma.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = jma.Last.Value;
jma.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = jma.Last.Value;
// Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void Jma_Reset_ClearsState()
{
var jma = new Jma(10);
jma.Update(new TValue(DateTime.UtcNow, 100));
jma.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = jma.Last.Value;
jma.Reset();
Assert.Equal(0, jma.Last.Value);
// After reset, should accept new values
jma.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, jma.Last.Value);
Assert.NotEqual(valueBefore, jma.Last.Value);
}
[Fact]
public void Jma_IsHot_BecomesTrueAfterWarmup()
{
var jma = new Jma(10);
Assert.False(jma.IsHot);
// Warmup for JMA(10) is approx 203 bars
// ceil(20 + 80 * 10^0.36) = 203
int warmup = (int)Math.Ceiling(20.0 + 80.0 * Math.Pow(10, 0.36));
for (int i = 1; i < warmup; i++)
{
jma.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.False(jma.IsHot);
}
jma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(jma.IsHot);
}
[Fact]
public void Jma_IterativeCorrections_RestoreToOriginalState()
{
var jma = new Jma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 20 new values (enough to fill buffer and stabilize)
TValue lastInput = default;
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
jma.Update(lastInput, isNew: true);
}
// Remember JMA state
double jmaAfter = jma.Last.Value;
// Generate 5 corrections with isNew=false (different values)
for (int i = 0; i < 5; i++)
{
var bar = gbm.Next(isNew: false);
jma.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered last input again with isNew=false
TValue finalJma = jma.Update(lastInput, isNew: false);
// JMA should match the original state
Assert.Equal(jmaAfter, finalJma.Value, 1e-10);
}
[Fact]
public void Jma_NaN_Input_UsesLastValidValue()
{
var jma = new Jma(10);
// Feed some valid values
jma.Update(new TValue(DateTime.UtcNow, 100));
jma.Update(new TValue(DateTime.UtcNow, 110));
// Feed NaN - should use last valid value (110)
var resultAfterNaN = jma.Update(new TValue(DateTime.UtcNow, double.NaN));
// Result should be finite (not NaN)
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Jma_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 = new Jma(10).Update(series);
// Calculate with Span API
Jma.Calculate(source.AsSpan(), output.AsSpan(), 10);
// Compare results
for (int i = 0; i < 100; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
}
}
[Fact]
public void Jma_AllModes_ProduceSameResult()
{
// Arrange
int period = 10;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// 1. Batch Mode
var batchSeries = new Jma(period).Update(series);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray();
var spanInput = new ReadOnlySpan<double>(tValues);
var spanOutput = new double[tValues.Length];
Jma.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Jma(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 Jma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
[Fact]
public void Jma_Phase_AffectsResult()
{
var series = new TSeries();
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);
series.Add(bar.Time, bar.Close);
}
var jmaPhase0 = new Jma(10, phase: 0).Update(series);
var jmaPhase100 = new Jma(10, phase: 100).Update(series);
var jmaPhaseMinus100 = new Jma(10, phase: -100).Update(series);
Assert.NotEqual(jmaPhase0.Last.Value, jmaPhase100.Last.Value);
Assert.NotEqual(jmaPhase0.Last.Value, jmaPhaseMinus100.Last.Value);
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class JmaValidationTests
{
[Fact]
public void Jma_FollowsPriceTrend()
{
// JMA should generally follow the price.
// If price goes up, JMA should eventually go up.
var jma = new Jma(10);
double previousJma = 0;
// Uptrend
for (int i = 0; i < 100; i++)
{
var result = jma.Update(new TValue(DateTime.UtcNow, i));
if (i > 20) // Allow warmup
{
Assert.True(result.Value > previousJma, $"JMA should be increasing in uptrend at step {i}");
}
previousJma = result.Value;
}
}
[Fact]
public void Jma_WithinBounds()
{
// JMA should stay within the range of recent prices (roughly)
// It's a moving average, so it shouldn't overshoot wildly unless phase is negative and high volatility?
// With default phase 0, it should be well behaved.
var jma = new Jma(10);
var gbm = new GBM(startPrice: 100, mu: 0, sigma: 0.5);
for (int i = 0; i < 1000; i++)
{
var bar = gbm.Next(isNew: true);
var result = jma.Update(new TValue(bar.Time, bar.Close));
if (i > 20)
{
// Update bounds of recent price history (simplified)
// This is a loose check.
// Just check it's finite and positive for this GBM
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value > 0);
}
}
}
}
+330
View File
@@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Jurik Moving Average (JMA):
/// - 10-bar SMA of local deviation
/// - 128-sample volatility distribution
/// - middle-65 trimmed mean as volatility reference
/// - Jurik dynamic exponent and 2-pole IIR core
/// </summary>
[SkipLocalsInit]
public sealed class Jma : ITValuePublisher
{
private const int VolWindowSize = 128; // volatility history length
private const int DevWindowSize = 10; // short SMA length for deviation
// Jurik core parameters derived from period/phase
private readonly double _phaseParam; // 0.5 .. 2.5
private readonly double _logParam; // log(sqrt(L))/log(2) + 2, clamped >= 0
private readonly double _lengthDivider; // L'/(L'+2), L' = 0.9*L
private readonly double _logSqrtDivider; // Precomputed log(_sqrtDivider) for Exp optimization
private readonly double _logLengthDivider; // Precomputed log(_lengthDivider) for Exp optimization
private readonly int _warmupBars; // for IsHot
// Constants for trimmed mean
private const int JurikTrimCount = 65; // canonical JMA: middle 65 of 128 samples
// Buffers
private readonly RingBuffer _devBuffer;
private readonly RingBuffer _volBuffer;
private readonly double[] _sorted;
// Streaming state (current + previous snapshot for isNew=false)
private State _state;
private State _p_state;
private record struct State
{
// Jurik "envelope" anchors
public double UpperBand;
public double LowerBand;
// IIR filter internal state
public double LastC0;
public double LastC8;
public double LastA8;
public double LastJma;
// last finite price (for NaN handling)
public double LastPrice;
// counters
public int Bars;
}
public string Name { get; }
public event Action<TValue>? Pub;
public TValue Last { get; private set; }
/// <summary>
/// JMA is considered "hot" when enough bars have passed to stabilize
/// the internal volatility distribution.
/// </summary>
public bool IsHot => _state.Bars >= _warmupBars;
public Jma(int period, int phase = 0, double power = 0.45)
{
if (period < 1)
throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 1.");
// --- Phase parameter: maps -100..100 -> 0.5..2.5 (Jurik convention) ---
if (phase < -100)
_phaseParam = 0.5;
else if (phase > 100)
_phaseParam = 2.5;
else
_phaseParam = (phase * 0.01) + 1.5;
// --- Length / log / divider parameters (from decompiled JMA) ---
// L_raw ~ (period - 1)/2, with a tiny lower bound to avoid log(0)
double lengthParam = period < 1.0000000002
? 0.0000000001
: (period - 1.0) / 2.0;
double logParam = Math.Log(Math.Sqrt(lengthParam)) / Math.Log(2.0);
logParam = (logParam + 2.0) < 0.0 ? 0.0 : (logParam + 2.0);
_logParam = logParam;
double sqrtParam = Math.Sqrt(lengthParam) * _logParam;
lengthParam *= 0.9;
_lengthDivider = lengthParam / (lengthParam + 2.0);
double sqrtDivider = sqrtParam / (sqrtParam + 1.0);
// Precompute logs for Math.Exp optimization
_logLengthDivider = Math.Log(_lengthDivider);
_logSqrtDivider = Math.Log(sqrtDivider);
// same warmup heuristic used in the AFL port (SetBarsRequired)
_warmupBars = (int)Math.Ceiling(20.0 + 80.0 * Math.Pow(period, 0.36));
Name = $"Jma({period},{phase},{power})"; // power kept for signature compatibility
_devBuffer = new RingBuffer(DevWindowSize);
_volBuffer = new RingBuffer(VolWindowSize);
_sorted = new double[VolWindowSize];
Reset();
}
public Jma(ITValuePublisher source, int period, int phase = 0, double power = 0.45)
: this(period, phase, power)
{
source.Pub += item => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_state = default;
_p_state = default;
_devBuffer.Clear();
_volBuffer.Clear();
Array.Clear(_sorted, 0, _sorted.Length);
Last = default;
}
/// <summary>
/// Core streaming step: feed a single value, get JMA.
/// Honors isNew semantics by snapshotting state+buffers.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double Step(double value, bool isNew)
{
// --- Snapshot/rollback support for "amending" last bar ---
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
// --- Handle NaN/inf: reuse last finite price ---
if (!double.IsFinite(value))
{
value = _state.Bars > 0 ? _state.LastPrice : 0.0;
}
else
{
_state.LastPrice = value;
}
_state.Bars++;
// --- First bar: initialize anchors and IIR state ---
if (_state.Bars == 1)
{
_state.UpperBand = value;
_state.LowerBand = value;
_state.LastC0 = value;
_state.LastC8 = 0.0;
_state.LastA8 = 0.0;
_state.LastJma = value;
return value;
}
// 1. Local deviation: |price - {UpperBand, LowerBand}|
double diffA = value - _state.UpperBand;
double diffB = value - _state.LowerBand;
double absA = Math.Abs(diffA);
double absB = Math.Abs(diffB);
double absValue = absA > absB ? absA : absB;
double deviation = absValue + 1e-10;
// 2. 10-bar SMA of local deviation -> "volatility"
_devBuffer.Add(deviation, isNew);
double volatility = _devBuffer.Average;
// 3. 128-bar volatility history + middle-65 trimmed mean
_volBuffer.Add(volatility, isNew);
double refVolatility = CalculateTrimmedMean(volatility);
if (refVolatility <= 0.0)
refVolatility = deviation;
// 4. Jurik dynamic exponent d from abs/refVolatility
// d = clamp( (abs/refVolatility)^p, 1 .. logParam )
double ratio = absValue / refVolatility;
if (ratio < 0.0) ratio = 0.0;
double p = Math.Max(_logParam - 2.0, 0.5);
double d = Math.Pow(ratio, p);
if (d > _logParam) d = _logParam;
if (d < 1.0) d = 1.0;
// 5. Update UpperBand / LowerBand using sqrtDivider ^ sqrt(d)
// Optimization: Use Exp(log(x) * y) instead of Pow(x, y)
double adapt = Math.Exp(_logSqrtDivider * Math.Sqrt(d));
_state.UpperBand = (value > _state.UpperBand) ? value : value - (value - _state.UpperBand) * adapt;
_state.LowerBand = (value < _state.LowerBand) ? value : value - (value - _state.LowerBand) * adapt;
// 6. 2-pole IIR core using d as the "speed"
// alpha = lengthDivider ^ d
// matches the Jurik decompiled structure (fC0/fC8/fA8)
double prevJma = _state.LastJma;
if (double.IsNaN(prevJma) || _state.Bars == 2)
prevJma = value;
double alpha = Math.Exp(_logLengthDivider * d);
double alpha2 = alpha * alpha;
double c0 = (1.0 - alpha) * value + alpha * _state.LastC0;
double c8 = (value - c0) * (1.0 - _lengthDivider) + _lengthDivider * _state.LastC8;
double a8 = (_phaseParam * c8 + c0 - prevJma) *
(alpha * (-2.0) + alpha2 + 1.0) +
alpha2 * _state.LastA8;
double jma = prevJma + a8;
_state.LastC0 = c0;
_state.LastC8 = c8;
_state.LastA8 = a8;
_state.LastJma = jma;
return jma;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
double j = Step(input.Value, isNew);
Last = new TValue(input.Time, j);
Pub?.Invoke(Last);
return Last;
}
/// <summary>
/// Batch update: recomputes JMA for entire series using the same
/// streaming core, so results match Update(TValue) applied bar-by-bar.
/// </summary>
public TSeries Update(TSeries source)
{
int n = source.Count;
if (n == 0)
return new TSeries(0);
var t = new List<long>(n);
var v = new List<double>(n);
CollectionsMarshal.SetCount(t, n);
CollectionsMarshal.SetCount(v, n);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
source.Times.CopyTo(tSpan);
Reset();
for (int i = 0; i < n; i++)
{
double j = Step(source.Values[i], true);
vSpan[i] = j;
}
return new TSeries(t, v);
}
/// <summary>
/// Static helper compatible with your existing signature.
/// </summary>
public static void Calculate(ReadOnlySpan<double> source,
Span<double> output,
int period,
int phase = 0,
double power = 0.45)
{
if (output.Length < source.Length)
throw new ArgumentException("output span is shorter than source span.", nameof(output));
var jma = new Jma(period, phase, power);
for (int i = 0; i < source.Length; i++)
{
output[i] = jma.Step(source[i], true);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateTrimmedMean(double fallback)
{
int count = _volBuffer.Count;
if (count < 16)
{
return fallback;
}
// Copy current buffer to _sorted for sorting
_volBuffer.CopyTo(_sorted, 0);
Array.Sort(_sorted, 0, count);
int start, end;
if (count >= VolWindowSize)
{
// canonical JMA: central 65 of 128 -> indices 32..96
// Approximately removes the outer 25% on each tail
int leftSkip = (int)Math.Ceiling((VolWindowSize - JurikTrimCount) / 2.0);
start = leftSkip;
end = start + JurikTrimCount - 1;
}
else
{
// for shorter history, use central ~50% as a reasonable proxy
int slice = (int)Math.Max(5, Math.Round(count * 0.5));
int drop = (count - slice) / 2;
start = drop;
end = drop + slice - 1;
}
if (start < 0) start = 0;
if (end >= count) end = count - 1;
int len = end - start + 1;
return _sorted.AsSpan(start, len).SumSIMD() / len;
}
}
+129
View File
@@ -0,0 +1,129 @@
# JMA - Jurik Moving Average
The Jurik Moving Average (JMA) is an advanced adaptive moving average that provides superior smoothing with minimal lag. It dynamically adjusts its response based on market volatility using a sophisticated multi-stage algorithm involving volatility distribution analysis and adaptive IIR filtering.
## Core Concepts
- **Volatility-Based Adaptation:** JMA uses a 128-sample volatility distribution with trimmed mean to estimate market conditions.
- **Dynamic Exponent:** The smoothing factor adjusts automatically based on the ratio of local deviation to reference volatility.
- **Phase Control:** Fine-tunes the balance between responsiveness and stability (-100 to +100).
- **Minimal Lag:** Tracks price action closely while filtering noise, outperforming traditional moving averages.
- **Warmup Period:** JMA requires approximately `20 + 80 × period^0.36` bars to stabilize its internal volatility distribution.
## Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| Period | 10 | The base period for the moving average calculation. |
| Phase | 0 | Phase shift (-100 to 100). Negative values reduce lag but may increase overshoot. Positive values increase smoothing and stability. |
| Power | 0.45 | Legacy parameter kept for API compatibility. Not actively used in current implementation. |
## Algorithm
JMA employs a sophisticated multi-stage process:
1. **Adaptive Envelope:** Maintains upper and lower bands that adapt to price movement using dynamic smoothing.
2. **Local Deviation:** Calculates the maximum absolute distance between price and the envelope bands.
3. **Short-Term Volatility:** Computes a 10-bar simple moving average of the local deviation.
4. **Volatility Distribution:** Maintains a rolling 128-sample buffer of the short-term volatility values.
5. **Reference Volatility:** Calculates a trimmed mean of the volatility distribution:
- Sorts the 128 samples
- Takes the central 65 samples (indices 32-96)
- Computes their mean, effectively removing outliers from both tails
6. **Dynamic Exponent:** Derives an adaptive smoothing factor:
- Computes ratio: `local_deviation / reference_volatility`
- Raises ratio to power `p = max(logParam - 2.0, 0.5)`
- Clamps result between 1.0 and `logParam`
7. **2-Pole IIR Filter:** Applies a dual-pole Infinite Impulse Response filter using the dynamic exponent to produce the final JMA value with controlled phase shift.
This implementation is a high-fidelity port of the reverse-engineered JMA algorithm found in AmiBroker and MT4, optimized for performance using logarithmic transformations for power calculations.
## Usage
### Standard Usage
```csharp
using QuanTAlib;
// Create JMA with period 10, phase 0
var jma = new Jma(period: 10, phase: 0);
// Update with new values
var result = jma.Update(new TValue(DateTime.UtcNow, 100.0));
// Check if the indicator has warmed up
if (jma.IsHot)
{
Console.WriteLine($"JMA: {jma.Last.Value}");
}
```
### Streaming (Event-driven)
```csharp
var source = new TSeries();
var jma = new Jma(source, period: 10);
source.Pub += (item) => {
if (jma.IsHot)
{
Console.WriteLine($"JMA: {jma.Last.Value}");
}
};
source.Add(new TValue(DateTime.UtcNow, 100.0));
```
### Batch Calculation
For high-performance batch processing:
```csharp
double[] prices = { 100.0, 101.5, 99.8, ... };
double[] output = new double[prices.Length];
Jma.Calculate(prices, output, period: 10, phase: 0);
```
### Batch with TSeries
```csharp
TSeries prices = GetPriceData();
var jma = new Jma(period: 10);
TSeries results = jma.Update(prices);
```
## Key Properties
- **IsHot:** Returns `true` when JMA has processed enough bars to stabilize its internal volatility distribution (approximately `20 + 80 × period^0.36` bars).
- **Last:** The most recent calculated JMA value.
- **Name:** Identifier string in format `"Jma(period,phase,power)"`.
## Interpretation
- **Trend Identification:** Rising JMA indicates uptrend; falling JMA indicates downtrend.
- **Dynamic Support/Resistance:** JMA often acts as adaptive support in uptrends and resistance in downtrends.
- **Crossovers:** Price crossing above JMA can signal bullish momentum; crossing below can signal bearish momentum.
- **Phase Adjustment:**
- Phase < 0: More responsive, faster signals, but may overshoot
- Phase = 0: Balanced (default)
- Phase > 0: Smoother, more stable, but with slightly more lag
- **Multi-Phase Ribbons:** Using multiple JMAs with different phases creates a visual "ribbon" showing trend strength and potential reversals.
## Performance Notes
- Uses `Math.Exp` optimization for power calculations (faster than `Math.Pow`)
- Employs SIMD operations for trimmed mean calculation
- Maintains minimal memory footprint with efficient buffer management
- Supports `isNew` parameter for bar amendment scenarios
## References
- [Jurik Research](http://www.jurikres.com/) - Original JMA developer
- [Pine Script Implementation](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/jma.pine)