From 60227a23c1b7457813ffd2c0616977c04374af25 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Fri, 12 Dec 2025 21:35:00 -0800 Subject: [PATCH] Implement Jurik Moving Average (JMA) with adaptive smoothing and comprehensive tests --- lib/trends/_index.md | 2 +- lib/trends/jma/Jma.Quantower.Tests.cs | 188 ++++++++++++++ lib/trends/jma/Jma.Quantower.cs | 71 ++++++ lib/trends/jma/Jma.Tests.cs | 239 ++++++++++++++++++ lib/trends/jma/Jma.Validation.Tests.cs | 54 ++++ lib/trends/jma/Jma.cs | 330 +++++++++++++++++++++++++ lib/trends/jma/Jma.md | 129 ++++++++++ 7 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 lib/trends/jma/Jma.Quantower.Tests.cs create mode 100644 lib/trends/jma/Jma.Quantower.cs create mode 100644 lib/trends/jma/Jma.Tests.cs create mode 100644 lib/trends/jma/Jma.Validation.Tests.cs create mode 100644 lib/trends/jma/Jma.cs create mode 100644 lib/trends/jma/Jma.md diff --git a/lib/trends/_index.md b/lib/trends/_index.md index a1950ca5..b911b35c 100644 --- a/lib/trends/_index.md +++ b/lib/trends/_index.md @@ -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 | | diff --git a/lib/trends/jma/Jma.Quantower.Tests.cs b/lib/trends/jma/Jma.Quantower.Tests.cs new file mode 100644 index 00000000..d6fd9a64 --- /dev/null +++ b/lib/trends/jma/Jma.Quantower.Tests.cs @@ -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); + } +} diff --git a/lib/trends/jma/Jma.Quantower.cs b/lib/trends/jma/Jma.Quantower.cs new file mode 100644 index 00000000..0bf05bf7 --- /dev/null +++ b/lib/trends/jma/Jma.Quantower.cs @@ -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); + } +} diff --git a/lib/trends/jma/Jma.Tests.cs b/lib/trends/jma/Jma.Tests.cs new file mode 100644 index 00000000..b103415f --- /dev/null +++ b/lib/trends/jma/Jma.Tests.cs @@ -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(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); + } +} diff --git a/lib/trends/jma/Jma.Validation.Tests.cs b/lib/trends/jma/Jma.Validation.Tests.cs new file mode 100644 index 00000000..31264b39 --- /dev/null +++ b/lib/trends/jma/Jma.Validation.Tests.cs @@ -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); + } + } + } +} diff --git a/lib/trends/jma/Jma.cs b/lib/trends/jma/Jma.cs new file mode 100644 index 00000000..28c2834b --- /dev/null +++ b/lib/trends/jma/Jma.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// 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 +/// +[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? Pub; + public TValue Last { get; private set; } + + /// + /// JMA is considered "hot" when enough bars have passed to stabilize + /// the internal volatility distribution. + /// + 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; + } + + /// + /// Core streaming step: feed a single value, get JMA. + /// Honors isNew semantics by snapshotting state+buffers. + /// + [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; + } + + /// + /// Batch update: recomputes JMA for entire series using the same + /// streaming core, so results match Update(TValue) applied bar-by-bar. + /// + public TSeries Update(TSeries source) + { + int n = source.Count; + if (n == 0) + return new TSeries(0); + + var t = new List(n); + var v = new List(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); + } + + /// + /// Static helper compatible with your existing signature. + /// + public static void Calculate(ReadOnlySpan source, + Span 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; + } +} diff --git a/lib/trends/jma/Jma.md b/lib/trends/jma/Jma.md new file mode 100644 index 00000000..9d5f403d --- /dev/null +++ b/lib/trends/jma/Jma.md @@ -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)