Add PWMA implementation and tests; enhance documentation

This commit is contained in:
Miha Kralj
2025-12-13 20:21:21 -08:00
parent 60227a23c1
commit 4b17984cfd
38 changed files with 3506 additions and 25 deletions
+48
View File
@@ -0,0 +1,48 @@
# Momentum
Momentum indicators measure the speed or strength of price movements. This includes classic oscillators and rate-of-change indicators.
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
| AC | Acceleration Oscillator | |
| ADX | Average Directional Movement Index | |
| ADXR | Average Directional Movement Rating | |
| AO | Awesome Oscillator | |
| APO | Absolute Price Oscillator | |
| AROON | Aroon | |
| AROONOSC | Aroon Oscillator | |
| BBB | Bollinger %B | |
| BBS | Bollinger Band Squeeze | |
| BOP | Balance of Power | |
| CCI | Commodity Channel Index | |
| CHOP | Choppiness Index | |
| CMO | Chande Momentum Oscillator | |
| DMX | Jurik Directional Movement Index | |
| DPO | Detrended Price Oscillator | |
| DX | Directional Movement Index | |
| FISHER | Ehlers Fisher Transform | |
| IMI | Intraday Momentum Index | |
| INERTIA | Inertia | |
| KDJ | KDJ Indicator | |
| MACD | Moving Average Convergence Divergence | |
| MOM | Momentum | |
| PGO | Pretty Good Oscillator | |
| PMO | Price Momentum Oscillator | |
| PPO | Percentage Price Oscillator | |
| PRS | Price Relative Strength | |
| QSTICK | Qstick Indicator | |
| ROC | Rate of Change | |
| ROCP | Rate of Change Percentage | |
| ROCR | Rate of Change Ratio | |
| RSI | Relative Strength Index | |
| [RSX](rsx/Rsx.md) | Relative Strength X (Jurik's RSI Variant) | Noise-free, zero-lag version of RSI |
| SMI | Stochastic Momentum Index | |
| STOCH | Stochastic Oscillator | |
| STOCHF | Stochastic Fast | |
| STOCHRSI | Stochastic RSI | |
| TRIX | Triple Exponential Average | |
| TSI | True Strength Index | |
| ULTOSC | Ultimate Oscillator | |
| [VEL](vel/Vel.md) | Jurik Velocity | Momentum oscillator calculated as the difference between Parabolic Weighted MA and Weighted MA. |
| VORTEX | Vortex Indicator | |
| WILLR | Williams %R | |
+179
View File
@@ -0,0 +1,179 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class RsxIndicatorTests
{
[Fact]
public void RsxIndicator_Constructor_SetsDefaults()
{
var indicator = new RsxIndicator();
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("RSX - Relative Strength X", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void RsxIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new RsxIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(20, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void RsxIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new RsxIndicator { Period = 15 };
Assert.Contains("RSX", indicator.ShortName);
Assert.Contains("15", indicator.ShortName);
}
[Fact]
public void RsxIndicator_SourceCodeLink_IsValid()
{
var indicator = new RsxIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Rsx.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void RsxIndicator_Initialize_CreatesInternalRsx()
{
var indicator = new RsxIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void RsxIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new RsxIndicator { 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 RsxIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new RsxIndicator { 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 RsxIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new RsxIndicator { 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 RsxIndicator_OnPaintChart_DoesNotThrow()
{
var indicator = new RsxIndicator();
indicator.Initialize();
var method = indicator.GetType().GetMethod("OnPaintChart");
Assert.NotNull(method);
Assert.Equal(typeof(RsxIndicator), method.DeclaringType);
}
[Fact]
public void RsxIndicator_MultipleUpdates_ProducesCorrectRsxSequence()
{
var indicator = new RsxIndicator { 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 RsxIndicator_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 RsxIndicator { 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 RsxIndicator_Period_CanBeChanged()
{
var indicator = new RsxIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(20, indicator.MinHistoryDepths);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class RsxIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Rsx? _rsx;
protected LineSeries? Series;
protected string? SourceName;
private int _warmupBarIndex = -1;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"RSX {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/rsx/Rsx.Quantower.cs";
public RsxIndicator()
{
OnBackGround = true;
SeparateWindow = true;
SourceName = Source.ToString();
Name = "RSX - Relative Strength X";
Description = "Jurik's RSX: A noise-free, zero-lag version of RSI";
Series = new(name: $"RSX {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_rsx = new Rsx(Period);
SourceName = Source.ToString();
_warmupBarIndex = -1;
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
TValue input = this.GetInputValue(args, Source);
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TValue result = _rsx!.Update(input, isNew);
Series!.SetValue(result.Value);
Series!.SetMarker(0, Color.Transparent);
if (_warmupBarIndex < 0 && _rsx!.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);
}
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using Xunit;
namespace QuanTAlib;
public class RsxTests
{
private readonly GBM _gbm;
public RsxTests()
{
_gbm = new GBM();
}
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Rsx(0));
Assert.Throws<ArgumentException>(() => new Rsx(-1));
}
[Fact]
public void Update_ValidInput_ReturnsValidRsx()
{
var rsx = new Rsx(14);
var result = rsx.Update(new TValue(DateTime.UtcNow, 100));
Assert.InRange(result.Value, 0, 100);
}
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var rsx = new Rsx(14);
rsx.Update(new TValue(DateTime.UtcNow, 100));
var result = rsx.Update(new TValue(DateTime.UtcNow, double.NaN));
// Should not be NaN
Assert.False(double.IsNaN(result.Value));
Assert.InRange(result.Value, 0, 100);
}
[Fact]
public void Update_IsNew_Consistency()
{
var rsx = new Rsx(14);
var time = DateTime.UtcNow;
// Update with isNew=true
var val1 = rsx.Update(new TValue(time, 100), true);
// Update with isNew=false (same time, different value)
rsx.Update(new TValue(time, 105), false);
// Update with isNew=false (same time, original value) - should match val1 if state rollback works
// Note: RSX is highly sensitive to path, so exact match might be tricky if intermediate states drift,
// but for a single step rollback it should be very close.
var val3 = rsx.Update(new TValue(time, 100), false);
Assert.Equal(val1.Value, val3.Value, 1e-9);
}
[Fact]
public void Calculate_Span_Matches_Update()
{
int period = 14;
int count = 100;
var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var rsx = new Rsx(period);
var resultSeries = rsx.Update(series);
var spanInput = series.Values.ToArray();
var spanOutput = new double[count];
Rsx.Calculate(spanInput, spanOutput, period);
for (int i = 0; i < count; i++)
{
Assert.Equal(resultSeries.Values[i], spanOutput[i], 1e-9);
}
}
[Fact]
public void Reset_ClearsState()
{
var rsx = new Rsx(14);
rsx.Update(new TValue(DateTime.UtcNow, 100));
rsx.Reset();
// After reset, it should behave like a new instance
// RSX initializes with 0 filters.
// If we feed it the same value, it should produce the same initial output.
// However, RSX output depends on change (v8), so first value sets LastF8 but v8=0.
var val1 = rsx.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(50.0, val1.Value); // Neutral start
}
[Fact]
public void Chain_Works()
{
var rsx = new Rsx(14);
var rsx2 = new Rsx(rsx, 14);
var result = rsx2.Update(new TValue(DateTime.UtcNow, 100));
Assert.False(double.IsNaN(result.Value));
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using Xunit;
namespace QuanTAlib;
public class RsxValidationTests
{
private readonly GBM _gbm;
public RsxValidationTests()
{
_gbm = new GBM();
}
[Fact]
public void Validate_Against_Reference_Implementation()
{
// Generate data
int count = 1000;
int period = 14;
var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var prices = bars.Close.Values;
// QuanTAlib implementation
var rsx = new Rsx(period);
var quantalibResults = new double[count];
for (int i = 0; i < count; i++)
{
quantalibResults[i] = rsx.Update(new TValue(DateTime.UtcNow, prices[i])).Value;
}
// Reference implementation (from user prompt)
var refRsx = new ReferenceRsx(period);
var refResults = new double[count];
for (int i = 0; i < count; i++)
{
refResults[i] = refRsx.Add(prices[i]);
}
// Compare
for (int i = 0; i < count; i++)
{
// Allow small difference due to floating point arithmetic order
Assert.Equal(refResults[i], quantalibResults[i], 1e-9);
}
}
// Reference implementation provided in the task description
private class ReferenceRsx
{
private readonly double alpha, ialpha;
// Internal state variables for filter registers:
private double f28, f30, f38, f40, f48, f50;
private double f58, f60, f68, f70, f78, f80;
// Added state for f10 logic
private double lastF8;
private bool initialized;
public double Current { get; private set; }
public ReferenceRsx(int length)
{
// Initialize constants:
this.alpha = 3.0 / (length + 2.0);
this.ialpha = 1.0 - this.alpha;
// Initialize filters to 0:
f28 = f30 = f38 = f40 = f48 = f50 = 0.0;
f58 = f60 = f68 = f70 = f78 = f80 = 0.0;
this.Current = 50.0; // neutral start
this.initialized = false;
}
public double Add(double price)
{
// Core RSX calculations (assuming price input as closing price):
double f8 = 100 * price;
if (!initialized)
{
lastF8 = f8;
initialized = true;
}
double v8 = f8 - lastF8;
lastF8 = f8;
// First smoothing stage:
f28 = ialpha * f28 + alpha * v8;
f30 = alpha * f28 + ialpha * f30;
double vC = 1.5 * f28 - 0.5 * f30;
// Second smoothing stage:
f38 = ialpha * f38 + alpha * vC;
f40 = alpha * f38 + ialpha * f40;
double v10 = 1.5 * f38 - 0.5 * f40;
// Third smoothing stage:
f48 = ialpha * f48 + alpha * v10;
f50 = alpha * f48 + ialpha * f50;
double v14 = 1.5 * f48 - 0.5 * f50;
// Repeat stages for absolute value (momentum magnitude):
f58 = ialpha * f58 + alpha * Math.Abs(v8);
f60 = alpha * f58 + ialpha * f60;
double v18 = 1.5 * f58 - 0.5 * f60;
f68 = ialpha * f68 + alpha * v18;
f70 = alpha * f68 + ialpha * f70;
double v1C = 1.5 * f68 - 0.5 * f70;
f78 = ialpha * f78 + alpha * v1C;
f80 = alpha * f78 + ialpha * f80;
double v20 = 1.5 * f78 - 0.5 * f80;
// Final RSX value:
double rsx;
if (v20 > 1e-10) // Avoid division by zero
{
double v4 = (v14 / v20 + 1.0) * 50.0;
rsx = Math.Clamp(v4, 0.0, 100.0);
}
else
{
rsx = 50.0;
}
this.Current = rsx;
return rsx;
}
}
}
+306
View File
@@ -0,0 +1,306 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RSX: Relative Strength X (Jurik's RSI Variant)
/// </summary>
/// <remarks>
/// RSX is a noise-free version of RSI that eliminates lag and choppiness.
/// It uses a cascading IIR filter structure to achieve smoothness while preserving
/// turning points and the 0-100 range.
///
/// Key characteristics:
/// - Zero lag (compared to smoothed RSI)
/// - Ultra smooth output
/// - Bounded 0-100
///
/// Sources:
/// - https://scribd.com/document/253633684/Jurik-RSX
/// - https://www.prorealcode.com/prorealtime-indicators/jurik-rsx/
/// </remarks>
[SkipLocalsInit]
public sealed class Rsx : ITValuePublisher
{
private readonly int _period;
private readonly double _alpha;
private record struct State
{
// Momentum filters (3 stages, 2 filters each)
public double M1_1, M1_2;
public double M2_1, M2_2;
public double M3_1, M3_2;
// Absolute Momentum filters (3 stages, 2 filters each)
public double A1_1, A1_2;
public double A2_1, A2_2;
public double A3_1, A3_2;
public double LastPrice;
public double LastValidValue;
public bool IsInitialized;
}
private State _state;
private State _p_state;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Creates RSX with specified period.
/// </summary>
/// <param name="period">Length of the filter (typically 8-40).</param>
public Rsx(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_period = period;
_alpha = 3.0 / (period + 2.0);
Name = $"Rsx({period})";
}
public Rsx(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
/// <summary>
/// Current RSX value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the indicator has processed enough data to be considered valid.
/// </summary>
public bool IsHot => _state.IsInitialized;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double price = input.Value;
if (!double.IsFinite(price))
{
price = _state.LastValidValue;
}
else
{
_state.LastValidValue = price;
}
if (!_state.IsInitialized)
{
_state.LastPrice = price;
_state.IsInitialized = true;
}
// Calculate momentum (change in price * 100)
double momentum = (price - _state.LastPrice) * 100.0;
if (isNew)
{
_state.LastPrice = price;
}
// --- Momentum Smoothing ---
// Stage 1
_state.M1_1 += _alpha * (momentum - _state.M1_1);
_state.M1_2 += _alpha * (_state.M1_1 - _state.M1_2);
double m1_out = (3.0 * _state.M1_1 - _state.M1_2) * 0.5;
// Stage 2
_state.M2_1 += _alpha * (m1_out - _state.M2_1);
_state.M2_2 += _alpha * (_state.M2_1 - _state.M2_2);
double m2_out = (3.0 * _state.M2_1 - _state.M2_2) * 0.5;
// Stage 3
_state.M3_1 += _alpha * (m2_out - _state.M3_1);
_state.M3_2 += _alpha * (_state.M3_1 - _state.M3_2);
double smoothedMomentum = (3.0 * _state.M3_1 - _state.M3_2) * 0.5;
// --- Absolute Momentum Smoothing ---
double absMomentum = Math.Abs(momentum);
// Stage 1
_state.A1_1 += _alpha * (absMomentum - _state.A1_1);
_state.A1_2 += _alpha * (_state.A1_1 - _state.A1_2);
double a1_out = (3.0 * _state.A1_1 - _state.A1_2) * 0.5;
// Stage 2
_state.A2_1 += _alpha * (a1_out - _state.A2_1);
_state.A2_2 += _alpha * (_state.A2_1 - _state.A2_2);
double a2_out = (3.0 * _state.A2_1 - _state.A2_2) * 0.5;
// Stage 3
_state.A3_1 += _alpha * (a2_out - _state.A3_1);
_state.A3_2 += _alpha * (_state.A3_1 - _state.A3_2);
double smoothedAbsMomentum = (3.0 * _state.A3_1 - _state.A3_2) * 0.5;
// --- Final RSX Calculation ---
double rsx;
if (smoothedAbsMomentum > 1e-10)
{
double v4 = (smoothedMomentum / smoothedAbsMomentum + 1.0) * 50.0;
rsx = Math.Clamp(v4, 0.0, 100.0);
}
else
{
rsx = 50.0;
}
Last = new TValue(input.Time, rsx);
Pub?.Invoke(Last);
return Last;
}
public 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);
Calculate(source.Values, vSpan, _period);
source.Times.CopyTo(tSpan);
// Restore state by replaying the last few bars
Reset();
int warmup = Math.Max(0, len - 200);
for (int i = warmup; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), true);
}
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public static TSeries Calculate(TSeries source, int period)
{
var rsx = new Rsx(period);
return rsx.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
int len = source.Length;
if (len == 0) return;
double alpha = 3.0 / (period + 2.0);
// Momentum filters
double m1_1 = 0, m1_2 = 0;
double m2_1 = 0, m2_2 = 0;
double m3_1 = 0, m3_2 = 0;
// Abs Momentum filters
double a1_1 = 0, a1_2 = 0;
double a2_1 = 0, a2_2 = 0;
double a3_1 = 0, a3_2 = 0;
double lastPrice = 0;
bool initialized = false;
double lastValidValue = 0;
for (int i = 0; i < len; i++)
{
double price = source[i];
if (!double.IsFinite(price))
{
price = lastValidValue;
}
else
{
lastValidValue = price;
}
if (!initialized)
{
lastPrice = price;
initialized = true;
}
double momentum = (price - lastPrice) * 100.0;
lastPrice = price;
// Momentum Smoothing
m1_1 += alpha * (momentum - m1_1);
m1_2 += alpha * (m1_1 - m1_2);
double m1_out = (3.0 * m1_1 - m1_2) * 0.5;
m2_1 += alpha * (m1_out - m2_1);
m2_2 += alpha * (m2_1 - m2_2);
double m2_out = (3.0 * m2_1 - m2_2) * 0.5;
m3_1 += alpha * (m2_out - m3_1);
m3_2 += alpha * (m3_1 - m3_2);
double smoothedMomentum = (3.0 * m3_1 - m3_2) * 0.5;
// Abs Momentum Smoothing
double absMomentum = Math.Abs(momentum);
a1_1 += alpha * (absMomentum - a1_1);
a1_2 += alpha * (a1_1 - a1_2);
double a1_out = (3.0 * a1_1 - a1_2) * 0.5;
a2_1 += alpha * (a1_out - a2_1);
a2_2 += alpha * (a2_1 - a2_2);
double a2_out = (3.0 * a2_1 - a2_2) * 0.5;
a3_1 += alpha * (a2_out - a3_1);
a3_2 += alpha * (a3_1 - a3_2);
double smoothedAbsMomentum = (3.0 * a3_1 - a3_2) * 0.5;
// Final RSX
double rsx;
if (smoothedAbsMomentum > 1e-10)
{
double v4 = (smoothedMomentum / smoothedAbsMomentum + 1.0) * 50.0;
rsx = Math.Clamp(v4, 0.0, 100.0);
}
else
{
rsx = 50.0;
}
output[i] = rsx;
}
}
public void Reset()
{
_state = default;
_p_state = default;
Last = default;
}
}
+68
View File
@@ -0,0 +1,68 @@
# RSX - Relative Strength X (Jurik's RSI Variant)
RSX is a noise-free version of the Relative Strength Index (RSI) developed by Mark Jurik. It eliminates the lag and choppiness associated with standard RSI and its smoothed variants. RSX preserves the 0-100 bounded range and turning points of RSI but provides a much smoother signal, making it easier to identify trends and reversals without false signals from whipsaw movements.
## Core Concepts
- **Zero Lag:** Uses a specialized IIR filter chain to smooth the data without introducing significant delay.
- **Noise Reduction:** Filters out high-frequency noise while retaining the underlying trend.
- **Bounded Range:** Output is strictly bounded between 0 and 100, similar to RSI.
- **Smoothness:** Produces a clean, continuous curve suitable for precise peak/valley detection.
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| Period | int | 14 | The smoothing period (typically 8-40). |
## Formula
RSX uses a cascading filter structure. The smoothing factor $\alpha$ is derived from the period:
$$ \alpha = \frac{3}{Period + 2} $$
The algorithm processes price changes ($v_8$) through multiple smoothing stages for both the raw momentum and its absolute value. The final RSX is calculated as:
$$ RSX = \left( \frac{v_{14}}{v_{20}} + 1 \right) \times 50 $$
Where $v_{14}$ is the smoothed momentum and $v_{20}$ is the smoothed absolute momentum.
## C# Implementation
### Standard Usage
```csharp
using QuanTAlib;
var rsx = new Rsx(14);
var result = rsx.Update(new TValue(DateTime.UtcNow, price));
Console.WriteLine($"RSX: {result.Value}");
```
### Span API (High Performance)
```csharp
double[] prices = { ... };
double[] results = new double[prices.Length];
Rsx.Calculate(prices, results, 14);
```
### Chaining
```csharp
var rsx = new Rsx(14);
var sma = new Sma(rsx, 3); // Smooth the RSX further
```
## Interpretation
- **Overbought/Oversold:** Values above 70 (or 80) indicate overbought conditions, while values below 30 (or 20) indicate oversold conditions.
- **Trend Confirmation:** RSX crossing 50 can signal a trend change.
- **Divergence:** Divergence between price and RSX often precedes a reversal.
- **Smoothness:** Due to its smoothness, RSX slope changes are more significant than RSI slope changes.
## References
- [Jurik Research](http://www.jurikres.com/)
- [ProRealCode - Jurik RSX](https://www.prorealcode.com/prorealtime-indicators/jurik-rsx/)
+179
View File
@@ -0,0 +1,179 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class VelIndicatorTests
{
[Fact]
public void VelIndicator_Constructor_SetsDefaults()
{
var indicator = new VelIndicator();
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("VEL - Jurik's Velocity", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void VelIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new VelIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(20, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void VelIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new VelIndicator { Period = 15 };
Assert.Contains("VEL", indicator.ShortName);
Assert.Contains("15", indicator.ShortName);
}
[Fact]
public void VelIndicator_SourceCodeLink_IsValid()
{
var indicator = new VelIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Vel.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void VelIndicator_Initialize_CreatesInternalVel()
{
var indicator = new VelIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void VelIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VelIndicator { 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 VelIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VelIndicator { 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 VelIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new VelIndicator { 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 VelIndicator_OnPaintChart_DoesNotThrow()
{
var indicator = new VelIndicator();
indicator.Initialize();
var method = indicator.GetType().GetMethod("OnPaintChart");
Assert.NotNull(method);
Assert.Equal(typeof(VelIndicator), method.DeclaringType);
}
[Fact]
public void VelIndicator_MultipleUpdates_ProducesCorrectVelSequence()
{
var indicator = new VelIndicator { 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 VelIndicator_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 VelIndicator { 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 VelIndicator_Period_CanBeChanged()
{
var indicator = new VelIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(20, indicator.MinHistoryDepths);
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class VelIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
public int Period { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Vel? _vel;
private int _warmupBarIndex = -1;
protected LineSeries? Series;
protected string? SourceName;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"VEL {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/vel/Vel.Quantower.cs";
public VelIndicator()
{
OnBackGround = true;
SeparateWindow = true;
SourceName = Source.ToString();
Name = "VEL - Jurik's Velocity";
Description = "Momentum oscillator calculated as PWMA - WMA";
Series = new(name: $"VEL {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_vel = new Vel(Period);
_warmupBarIndex = -1;
SourceName = Source.ToString();
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 = _vel!.Update(input, isNew);
if (_warmupBarIndex < 0 && _vel!.IsHot)
_warmupBarIndex = Count;
Series!.SetValue(result.Value);
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
}
public override void OnPaintChart(PaintChartEventArgs args)
{
base.OnPaintChart(args);
this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2);
}
}
+210
View File
@@ -0,0 +1,210 @@
using Xunit;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class VelTests
{
[Fact]
public void Vel_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Vel(0));
Assert.Throws<ArgumentException>(() => new Vel(-1));
var vel = new Vel(10);
Assert.NotNull(vel);
}
[Fact]
public void Vel_Calc_ReturnsValue()
{
var vel = new Vel(10);
Assert.Equal(0, vel.Last.Value);
TValue result = vel.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result.Value, vel.Last.Value);
}
[Fact]
public void Vel_Calc_IsNew_AcceptsParameter()
{
var vel = new Vel(10);
vel.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = vel.Last.Value;
vel.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
double value2 = vel.Last.Value;
// Values should change with new bars
Assert.NotEqual(value1, value2);
}
[Fact]
public void Vel_Calc_IsNew_False_UpdatesValue()
{
var vel = new Vel(10);
vel.Update(new TValue(DateTime.UtcNow, 100));
vel.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = vel.Last.Value;
vel.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = vel.Last.Value;
// Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void Vel_Reset_ClearsState()
{
var vel = new Vel(10);
vel.Update(new TValue(DateTime.UtcNow, 100));
vel.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = vel.Last.Value;
vel.Reset();
Assert.Equal(0, vel.Last.Value);
// After reset, should accept new values
vel.Update(new TValue(DateTime.UtcNow, 50));
// First value is 0 because PWMA(50) = 50 and WMA(50) = 50
Assert.Equal(0, vel.Last.Value);
vel.Update(new TValue(DateTime.UtcNow, 60));
Assert.NotEqual(0, vel.Last.Value);
Assert.NotEqual(valueBefore, vel.Last.Value);
}
[Fact]
public void Vel_IsHot_BecomesTrueWhenBufferFull()
{
var vel = new Vel(5);
Assert.False(vel.IsHot);
for (int i = 1; i <= 4; i++)
{
vel.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.False(vel.IsHot);
}
vel.Update(new TValue(DateTime.UtcNow, 50));
Assert.True(vel.IsHot);
}
[Fact]
public void Vel_CalculatesCorrectValue()
{
var vel = new Vel(3);
vel.Update(new TValue(DateTime.UtcNow, 10));
vel.Update(new TValue(DateTime.UtcNow, 20));
vel.Update(new TValue(DateTime.UtcNow, 30));
// PWMA(3) of 10,20,30 = 360/14 = 25.7142857...
// WMA(3) of 10,20,30 = 140/6 = 23.3333333...
// VEL = PWMA - WMA = 2.38095238...
double expectedPwma = 360.0 / 14.0;
double expectedWma = 140.0 / 6.0;
double expectedVel = expectedPwma - expectedWma;
Assert.Equal(expectedVel, vel.Last.Value, 1e-10);
}
[Fact]
public void Vel_StaticCalculate_Works()
{
var series = new TSeries();
series.Add(DateTime.UtcNow.Ticks, 10);
series.Add(DateTime.UtcNow.Ticks + 1, 20);
series.Add(DateTime.UtcNow.Ticks + 2, 30);
var results = Vel.Calculate(series, 3);
Assert.Equal(3, results.Count);
double expectedPwma = 360.0 / 14.0;
double expectedWma = 140.0 / 6.0;
double expectedVel = expectedPwma - expectedWma;
Assert.Equal(expectedVel, results.Last.Value, 1e-10);
}
[Fact]
public void Vel_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 = Vel.Calculate(series, 10);
// Calculate with Span API
Vel.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 Vel_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 = Vel.Calculate(series, period);
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];
Vel.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Vel(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 Vel(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: 8);
Assert.Equal(expected, eventingResult, precision: 8);
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class VelValidationTests
{
[Fact]
public void Vel_Matches_PwmaMinusWma()
{
// VEL = PWMA - WMA
// We validate this relationship holds true for a random sequence of data.
int period = 10;
var vel = new Vel(period);
var pwma = new Pwma(period);
var wma = new Wma(period);
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);
var input = new TValue(bar.Time, bar.Close);
var v = vel.Update(input);
var p = pwma.Update(input);
var w = wma.Update(input);
Assert.Equal(p.Value - w.Value, v.Value, 1e-10);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VEL: Jurik's Velocity
/// </summary>
/// <remarks>
/// VEL is a momentum oscillator calculated as the difference between a Parabolic Weighted Moving Average (PWMA)
/// and a Weighted Moving Average (WMA) of the same period.
///
/// Calculation:
/// VEL = PWMA(Period) - WMA(Period)
///
/// This indicator measures the rate of change of the price, smoothed by the difference in weighting schemes.
/// </remarks>
[SkipLocalsInit]
public sealed class Vel : ITValuePublisher
{
private readonly Pwma _pwma;
private readonly Wma _wma;
public string Name { get; }
public TValue Last { get; private set; }
public bool IsHot => _pwma.IsHot && _wma.IsHot;
public event Action<TValue>? Pub;
public Vel(int period)
{
if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period));
_pwma = new Pwma(period);
_wma = new Wma(period);
Name = $"Vel({period})";
}
public Vel(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
var pwma = _pwma.Update(input, isNew);
var wma = _wma.Update(input, isNew);
Last = new TValue(input.Time, pwma.Value - wma.Value);
Pub?.Invoke(Last);
return Last;
}
public TSeries Update(TSeries source)
{
if (source.Count == 0) return [];
// Update internal indicators to ensure their state is correct
var pwmaSeries = _pwma.Update(source);
var wmaSeries = _wma.Update(source);
// Calculate VEL series
int len = source.Count;
List<long> t = new(len);
List<double> v = new(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var vSpan = CollectionsMarshal.AsSpan(v);
SimdExtensions.Subtract(pwmaSeries.Values, wmaSeries.Values, vSpan);
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
Last = new TValue(t[len - 1], v[len - 1]);
return new TSeries(t, v);
}
public static TSeries Calculate(TSeries source, int period)
{
var vel = new Vel(period);
return vel.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
Span<double> pwma = source.Length <= 1024 ? stackalloc double[source.Length] : new double[source.Length];
Span<double> wma = source.Length <= 1024 ? stackalloc double[source.Length] : new double[source.Length];
Pwma.Calculate(source, pwma, period);
Wma.Calculate(source, wma, period);
SimdExtensions.Subtract(pwma, wma, output);
}
public void Reset()
{
_pwma.Reset();
_wma.Reset();
Last = default;
}
}
+66
View File
@@ -0,0 +1,66 @@
# VEL - Jurik's Velocity
VEL (Jurik's Velocity) is a momentum oscillator that measures the rate of change of price. It is calculated as the difference between a Parabolic Weighted Moving Average (PWMA) and a Weighted Moving Average (WMA) of the same period.
## Core Concepts
- **Momentum:** Measures the speed of price movement.
- **Smoothing:** Uses moving averages to reduce noise compared to raw ROC (Rate of Change).
- **Parabolic vs Linear:** By subtracting a linear weighted average from a parabolic weighted average, VEL isolates the acceleration component of the price movement.
## Formula
$$
VEL_t = PWMA_t(n) - WMA_t(n)
$$
Where:
- $n$ is the period.
- $PWMA_t(n)$ is the Parabolic Weighted Moving Average.
- $WMA_t(n)$ is the Weighted Moving Average.
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| Period | int | - | The number of data points used in the calculation. Must be >= 1. |
## Usage
### Standard Usage
```csharp
using QuanTAlib;
var vel = new Vel(14);
var result = vel.Update(new TValue(DateTime.UtcNow, 100.0));
Console.WriteLine($"VEL: {result.Value}");
```
### Chaining
```csharp
var source = new Sma(10);
var vel = new Vel(source, 14);
```
### Batch Calculation (Span)
For high-performance scenarios, use the static `Calculate` method with `Span<double>`.
```csharp
double[] prices = { ... };
double[] results = new double[prices.Length];
Vel.Calculate(prices, results, 14);
```
## Interpretation
- **Zero Line Crossovers:** Crossing above zero indicates increasing upward momentum (acceleration). Crossing below zero indicates increasing downward momentum (deceleration).
- **Divergence:** Divergence between price and VEL can signal potential reversals.
- **Extremes:** High positive or negative values indicate strong momentum, which might precede a reversal or consolidation.
## References
- Jurik Research