mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 20:48:04 +00:00
Add Stochastic Oscillator implementation and validation tests
- Implemented Stochastic Oscillator (%K and %D) in Stoch.cs with streaming and batch processing capabilities. - Added validation tests for the Stochastic Oscillator in Stoch.Validation.Tests.cs, ensuring consistency with Skender.Stock.Indicators. - Created documentation for the Stochastic Oscillator in Stoch.md, detailing its mathematical formula, architecture, parameters, and common pitfalls. - Updated project file to include necessary numeric libraries for highest and lowest calculations.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class InertiaIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void InertiaIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new InertiaIndicator();
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("INERTIA - Inertia Oscillator", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 20 };
|
||||
|
||||
Assert.Equal(0, InertiaIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("INERTIA", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new InertiaIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Inertia.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_Initialize_CreatesInternalInertia()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 10 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double value = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 130, 110, 125);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InertiaIndicator_Parameters_CanBeChanged()
|
||||
{
|
||||
var indicator = new InertiaIndicator { Period = 20 };
|
||||
|
||||
indicator.Period = 14;
|
||||
indicator.Source = SourceType.Open;
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
Assert.Equal(0, InertiaIndicator.MinHistoryDepths);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class InertiaIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput(sortIndex: 2)]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Inertia _inertia = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"INERTIA ({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/inertia/Inertia.Quantower.cs";
|
||||
|
||||
public InertiaIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "INERTIA - Inertia Oscillator";
|
||||
Description = "Linear regression residual measuring price deviation from trend";
|
||||
|
||||
_series = new LineSeries("INERTIA", Color.Yellow, 2, LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_inertia = new Inertia(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var priceSelector = Source.GetPriceSelector();
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double price = priceSelector(item);
|
||||
|
||||
TValue input = new(item.TimeLeft, price);
|
||||
TValue result = _inertia.Update(input, args.IsNewBar());
|
||||
|
||||
if (!_inertia.IsHot && !ShowColdValues)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_series.SetValue(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class InertiaTests
|
||||
{
|
||||
private const int DefaultPeriod = 20;
|
||||
|
||||
// === A) Constructor validation ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultPeriod_Is20()
|
||||
{
|
||||
var inertia = new Inertia();
|
||||
Assert.Equal(DefaultPeriod, inertia.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomPeriod_IsStored()
|
||||
{
|
||||
var inertia = new Inertia(period: 10);
|
||||
Assert.Equal(10, inertia.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Inertia(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Inertia(period: -1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
// === B) Basic calculation ===
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsFiniteValue()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_IsAccessible()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ContainsPeriod()
|
||||
{
|
||||
var inertia = new Inertia(period: 14);
|
||||
Assert.Contains("14", inertia.Name, StringComparison.Ordinal);
|
||||
Assert.Contains("Inertia", inertia.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// === C) State + bar correction ===
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
|
||||
}
|
||||
double first = inertia.Last.Value;
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
|
||||
Assert.NotEqual(first, inertia.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_CorrectsSameBar()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
double before = inertia.Last.Value;
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 200.0), isNew: false);
|
||||
double corrected = inertia.Last.Value;
|
||||
Assert.NotEqual(before, corrected);
|
||||
|
||||
// Restore original
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
|
||||
Assert.Equal(before, inertia.Last.Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_RestoreState()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
double baseline = inertia.Last.Value;
|
||||
|
||||
// Multiple corrections on same bar
|
||||
for (int c = 0; c < 5; c++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 150.0 + c), isNew: false);
|
||||
}
|
||||
|
||||
// Restore original value
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 109.0), isNew: false);
|
||||
Assert.Equal(baseline, inertia.Last.Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
Assert.True(inertia.IsHot);
|
||||
|
||||
inertia.Reset();
|
||||
Assert.False(inertia.IsHot);
|
||||
Assert.Equal(0.0, inertia.Last.Value);
|
||||
}
|
||||
|
||||
// === D) Warmup/convergence ===
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAtPeriod()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
Assert.False(inertia.IsHot);
|
||||
}
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 104.0));
|
||||
Assert.True(inertia.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesPeriod()
|
||||
{
|
||||
var inertia = new Inertia(period: 10);
|
||||
Assert.Equal(10, inertia.WarmupPeriod);
|
||||
}
|
||||
|
||||
// === E) Robustness ===
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValid()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
_ = inertia.Last.Value;
|
||||
inertia.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
inertia.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinity_UsesLastValid()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
inertia.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BatchNaN_StaysFinite()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// === F) Consistency (4 modes) ===
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResults()
|
||||
{
|
||||
int period = 10;
|
||||
int count = 50;
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
source.Add(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// Mode 1: Streaming
|
||||
var streaming = new Inertia(period);
|
||||
var streamResults = new double[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
streaming.Update(new TValue(source.Times[i], source.Values[i]));
|
||||
streamResults[i] = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Mode 2: Batch (TSeries)
|
||||
var batchResults = Inertia.Batch(source, period);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults.Values[i], precision: 4);
|
||||
}
|
||||
|
||||
// Mode 3: Span
|
||||
Span<double> spanOutput = new double[count];
|
||||
Inertia.Batch(source.Values, spanOutput, period);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanOutput[i], precision: 4);
|
||||
}
|
||||
|
||||
// Mode 4: Event-based
|
||||
var eventInertia = new Inertia(period);
|
||||
var eventResults = new double[count];
|
||||
int idx = 0;
|
||||
eventInertia.Pub += (object? _, in TValueEventArgs e) =>
|
||||
{
|
||||
if (idx < count)
|
||||
{
|
||||
eventResults[idx++] = e.Value.Value;
|
||||
}
|
||||
};
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
eventInertia.Update(new TValue(source.Times[i], source.Values[i]));
|
||||
}
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], eventResults[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
// === G) Span API tests ===
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MismatchedLength_Throws()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3 };
|
||||
var output = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Inertia.Batch(src.AsSpan(), output.AsSpan(), 3));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_ZeroPeriod_Throws()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3 };
|
||||
var output = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Inertia.Batch(src.AsSpan(), output.AsSpan(), 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_Empty_NoException()
|
||||
{
|
||||
var src = ReadOnlySpan<double>.Empty;
|
||||
var output = Span<double>.Empty;
|
||||
Inertia.Batch(src, output, 5);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MatchesTSeries()
|
||||
{
|
||||
int period = 5;
|
||||
int count = 30;
|
||||
var gbm = new GBM(startPrice: 100.0, seed: 42);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
source.Add(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
var batchTs = Inertia.Batch(source, period);
|
||||
|
||||
Span<double> spanOutput = new double[count];
|
||||
Inertia.Batch(source.Values, spanOutput, period);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(batchTs.Values[i], spanOutput[i], precision: 12);
|
||||
}
|
||||
}
|
||||
|
||||
// === H) Chainability ===
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
int count = 0;
|
||||
inertia.Pub += (object? _, in TValueEventArgs _) => count++;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
Assert.Equal(10, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_EventBased_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var inertia = new Inertia(source, period: 5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(inertia.IsHot);
|
||||
Assert.True(double.IsFinite(inertia.Last.Value));
|
||||
}
|
||||
|
||||
// === Mathematical behavior ===
|
||||
|
||||
[Fact]
|
||||
public void ConstantPrice_InertiaIsZero()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
}
|
||||
// Constant price → perfect regression → residual = 0
|
||||
Assert.Equal(0.0, inertia.Last.Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinearTrend_InertiaIsZero()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i * 2.0));
|
||||
}
|
||||
// Perfect linear trend → regression fits perfectly → residual = 0
|
||||
Assert.Equal(0.0, inertia.Last.Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RisingAboveTrend_InertiaPositive()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
// Feed a trend, then spike up
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
// Spike above the trend line
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 200.0));
|
||||
Assert.True(inertia.Last.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallingBelowTrend_InertiaNegative()
|
||||
{
|
||||
var inertia = new Inertia(period: 5);
|
||||
// Feed a trend, then drop
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
// Drop below the trend line
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 90.0));
|
||||
Assert.True(inertia.Last.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
var (results, indicator) = Inertia.Calculate(source, period: 10);
|
||||
Assert.Equal(30, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Inertia (linear regression residual).
|
||||
/// Cross-validates against manual OLS computation and our CFO/LinReg classes.
|
||||
/// No external library has an Inertia indicator — validated via math identity:
|
||||
/// Inertia = source - TSF, where TSF = slope*(period-1) + intercept.
|
||||
/// </summary>
|
||||
public sealed class InertiaValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public InertiaValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkipLocalsInit]
|
||||
public void Validate_Streaming_Batch_Span_Agree()
|
||||
{
|
||||
int period = 14;
|
||||
|
||||
// Streaming
|
||||
var streaming = new Inertia(period);
|
||||
var streamValues = new List<double>(_testData.Data.Count);
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
streamValues.Add(streaming.Update(item).Value);
|
||||
}
|
||||
|
||||
// Batch (TSeries)
|
||||
TSeries batchSeries = Inertia.Batch(_testData.Data, period);
|
||||
|
||||
// Span
|
||||
double[] src = _testData.RawData.ToArray();
|
||||
double[] spanOutput = new double[src.Length];
|
||||
Inertia.Batch(src.AsSpan(), spanOutput.AsSpan(), period);
|
||||
|
||||
// O(1) streaming sumXY maintenance accumulates cancellation drift vs full-recalc batch.
|
||||
// ResyncInterval=1000 bounds drift, but between resyncs tolerance must be relaxed.
|
||||
// Batch vs span should match exactly (same code path).
|
||||
int start = Math.Max(0, src.Length - 200);
|
||||
for (int i = start; i < src.Length; i++)
|
||||
{
|
||||
Assert.Equal(batchSeries[i].Value, spanOutput[i], 12); // batch≡span (same path)
|
||||
Assert.Equal(batchSeries[i].Value, streamValues[i], 4); // streaming drifts ~1e-5 between resyncs
|
||||
}
|
||||
|
||||
_output.WriteLine("Inertia validation: streaming, batch, and span outputs agree within tolerance.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkipLocalsInit]
|
||||
public void Validate_Against_CfoRelationship()
|
||||
{
|
||||
// Cross-validate Inertia against CFO.
|
||||
// Inertia = source - TSF
|
||||
// CFO = 100 * (source - TSF) / source
|
||||
// Therefore: Inertia = CFO * source / 100
|
||||
int[] periods = [5, 10, 14, 20, 50];
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var inertia = new Inertia(period);
|
||||
var cfo = new Cfo(period);
|
||||
|
||||
int validCount = 0;
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
inertia.Update(item);
|
||||
cfo.Update(item);
|
||||
|
||||
if (!inertia.IsHot || !cfo.IsHot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double src = item.Value;
|
||||
if (src == 0.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double expectedInertia = cfo.Last.Value * src / 100.0;
|
||||
double actualInertia = inertia.Last.Value;
|
||||
|
||||
// skipcq: CS-R1140 - Two independent O(1) streaming implementations accumulate floating-point drift independently
|
||||
Assert.True(Math.Abs(expectedInertia - actualInertia) < 1e-6,
|
||||
$"Inertia mismatch at period={period}: expected={expectedInertia}, actual={actualInertia}, diff={Math.Abs(expectedInertia - actualInertia)}");
|
||||
validCount++;
|
||||
}
|
||||
|
||||
Assert.True(validCount > 0, $"No valid comparison points for period {period}");
|
||||
_output.WriteLine($"Inertia period={period}: validated {validCount} points against CFO relationship.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkipLocalsInit]
|
||||
public void Validate_KnownValues_LinearTrend()
|
||||
{
|
||||
// For a perfect linear trend y = a + b*x, the regression line exactly fits.
|
||||
// TSF should equal the source value, so Inertia should be 0.
|
||||
int period = 5;
|
||||
var inertia = new Inertia(period);
|
||||
|
||||
// Feed a perfect linear trend: 10, 11, 12, 13, 14, 15, ...
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
inertia.Update(new TValue(DateTime.UtcNow, 10.0 + i));
|
||||
}
|
||||
|
||||
// After warmup, Inertia should be ~0 for a perfect linear trend
|
||||
Assert.Equal(0.0, inertia.Last.Value, 10);
|
||||
_output.WriteLine("Inertia known-values: perfect linear trend produces Inertia=0.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkipLocalsInit]
|
||||
public void Validate_ManualOls_LastWindow()
|
||||
{
|
||||
// Validate last Inertia value against manual OLS computation
|
||||
int period = 14;
|
||||
var inertia = new Inertia(period);
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
inertia.Update(item);
|
||||
}
|
||||
|
||||
// Manual OLS for the last window
|
||||
double[] raw = _testData.RawData.ToArray();
|
||||
int n = period;
|
||||
double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
|
||||
int windowStart = raw.Length - period;
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
double x = j;
|
||||
double y = raw[windowStart + j];
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumX2 += x * x;
|
||||
}
|
||||
|
||||
double denom = n * sumX2 - sumX * sumX;
|
||||
double slope = (n * sumXY - sumX * sumY) / denom;
|
||||
double intercept = (sumY - slope * sumX) / n;
|
||||
double tsf = slope * (n - 1) + intercept;
|
||||
double expected = raw[^1] - tsf;
|
||||
|
||||
_output.WriteLine($"Manual Inertia: {expected:F12}");
|
||||
_output.WriteLine($"Computed Inertia: {inertia.Last.Value:F12}");
|
||||
_output.WriteLine($"Delta: {Math.Abs(expected - inertia.Last.Value):E3}");
|
||||
|
||||
Assert.Equal(expected, inertia.Last.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkipLocalsInit]
|
||||
public void Validate_MultiPeriod_Consistency()
|
||||
{
|
||||
// Different periods should produce different results
|
||||
int[] periods = [5, 14, 50];
|
||||
var results = new List<TSeries>();
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
results.Add(Inertia.Batch(_testData.Data, period));
|
||||
}
|
||||
|
||||
// After all warmups, values should differ for different periods
|
||||
int checkIdx = 100;
|
||||
for (int i = 0; i < results.Count - 1; i++)
|
||||
{
|
||||
Assert.NotEqual(results[i][checkIdx].Value, results[i + 1][checkIdx].Value);
|
||||
}
|
||||
|
||||
_output.WriteLine("Inertia multi-period: different periods produce different results.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// INERTIA: Inertia Oscillator
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measures the raw distance between the current price and the
|
||||
/// Time Series Forecast (linear regression endpoint):
|
||||
/// <c>Inertia = source − TSF</c>
|
||||
///
|
||||
/// Positive values indicate price is above the regression line (bullish inertia);
|
||||
/// negative values indicate price is below (bearish inertia).
|
||||
///
|
||||
/// Uses O(1) incremental sumY / sumXY maintenance from the PineScript reference.
|
||||
///
|
||||
/// References:
|
||||
/// Donald Dorsey, "Relative Volatility Index", Technical Analysis of Stocks & Commodities, 1993
|
||||
/// PineScript reference: inertia.pine
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Inertia : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
// Precomputed linear regression constants (full window)
|
||||
private readonly double _sumX; // 0 + 1 + ... + (period-1)
|
||||
private readonly double _denomX; // period * sumX2 - sumX²
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double SumY,
|
||||
double SumXY,
|
||||
int Count,
|
||||
double LastValid);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
private const int ResyncInterval = 1000;
|
||||
private int _tickCount;
|
||||
|
||||
/// <summary>
|
||||
/// Creates Inertia with specified period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period for linear regression (must be > 0)</param>
|
||||
public Inertia(int period = 20)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Inertia({period})";
|
||||
WarmupPeriod = period;
|
||||
|
||||
_sumX = period * (period - 1) / 2.0;
|
||||
double sumX2 = period * (period - 1.0) * (2.0 * period - 1.0) / 6.0;
|
||||
_denomX = period * sumX2 - _sumX * _sumX;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Inertia with specified source and period.
|
||||
/// </summary>
|
||||
public Inertia(ITValuePublisher source, int period = 20) : this(period)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
/// <summary>
|
||||
/// Period of the indicator.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double value = input.Value;
|
||||
|
||||
// Sanitize input
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.LastValid = value;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
|
||||
// O(1) incremental sumXY maintenance (PineScript algorithm)
|
||||
if (_buffer.Count == _buffer.Capacity)
|
||||
{
|
||||
double oldest = _buffer.Oldest;
|
||||
_state.SumY -= oldest;
|
||||
_state.SumXY -= _state.SumY;
|
||||
_state.SumXY += (_period - 1) * value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.SumXY += _state.Count * value;
|
||||
_state.Count++;
|
||||
}
|
||||
|
||||
_state.SumY += value;
|
||||
_buffer.Add(value);
|
||||
|
||||
_tickCount++;
|
||||
if (_buffer.IsFull && _tickCount >= ResyncInterval)
|
||||
{
|
||||
_tickCount = 0;
|
||||
RecalculateSums();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
|
||||
_buffer.UpdateNewest(value);
|
||||
RecalculateSums();
|
||||
}
|
||||
|
||||
if (!_buffer.IsFull)
|
||||
{
|
||||
Last = new TValue(input.Time, 0.0);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// Linear regression: slope, intercept, TSF
|
||||
double slope = (_period * _state.SumXY - _sumX * _state.SumY) / _denomX;
|
||||
double intercept = (_state.SumY - slope * _sumX) / _period;
|
||||
double tsf = Math.FusedMultiplyAdd(slope, _period - 1, intercept);
|
||||
|
||||
// Inertia = source - TSF (raw residual, no normalization)
|
||||
double inertia = value - tsf;
|
||||
|
||||
Last = new TValue(input.Time, inertia);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
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);
|
||||
|
||||
Batch(source.Values, vSpan, _period);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Update internal state to match final position
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void RecalculateSums()
|
||||
{
|
||||
_state.SumY = 0.0;
|
||||
_state.SumXY = 0.0;
|
||||
_state.Count = _buffer.Count;
|
||||
for (int i = 0; i < _buffer.Count; i++)
|
||||
{
|
||||
double v = _buffer[i];
|
||||
_state.SumY += v;
|
||||
_state.SumXY += i * v;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = default;
|
||||
_p_state = default;
|
||||
_tickCount = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Inertia for entire series.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source, int period = 20)
|
||||
{
|
||||
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);
|
||||
|
||||
Batch(source.Values, vSpan, period);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch Inertia calculation with O(1) incremental linear regression.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 20)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double sumX = period * (period - 1) / 2.0;
|
||||
double sumX2 = period * (period - 1.0) * (2.0 * period - 1.0) / 6.0;
|
||||
double denomX = period * sumX2 - sumX * sumX;
|
||||
|
||||
double sumY = 0.0;
|
||||
double sumXY = 0.0;
|
||||
int count = 0;
|
||||
double lastValid = 0.0;
|
||||
|
||||
var valueBuffer = new RingBuffer(period);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
// O(1) incremental sumXY maintenance
|
||||
if (valueBuffer.Count == valueBuffer.Capacity)
|
||||
{
|
||||
double oldest = valueBuffer.Oldest;
|
||||
sumY -= oldest;
|
||||
sumXY -= sumY;
|
||||
sumXY += (period - 1) * val;
|
||||
}
|
||||
else
|
||||
{
|
||||
sumXY += count * val;
|
||||
count++;
|
||||
}
|
||||
|
||||
sumY += val;
|
||||
valueBuffer.Add(val);
|
||||
|
||||
if (count < period)
|
||||
{
|
||||
output[i] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double slope = (period * sumXY - sumX * sumY) / denomX;
|
||||
double intercept = (sumY - slope * sumX) / period;
|
||||
double tsf = Math.FusedMultiplyAdd(slope, period - 1, intercept);
|
||||
|
||||
output[i] = val - tsf;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Inertia for a series, returning both results and the indicator instance.
|
||||
/// </summary>
|
||||
public static (TSeries Results, Inertia Indicator) Calculate(TSeries source, int period = 20)
|
||||
{
|
||||
var indicator = new Inertia(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Inertia Oscillator
|
||||
|
||||
## Overview
|
||||
|
||||
The Inertia oscillator measures the raw distance between the current price and its linear regression forecast (Time Series Forecast). It quantifies how far price deviates from its expected trajectory, providing insight into trend strength and potential reversals.
|
||||
|
||||
## Origin
|
||||
|
||||
The Inertia concept is rooted in Donald Dorsey's work on the Relative Volatility Index (1993), where "inertia" describes the tendency of prices to continue in their current direction. The linear regression residual approach measures this momentum by comparing actual price to the statistically expected value.
|
||||
|
||||
## Mathematical Formula
|
||||
|
||||
Given a lookback period *n*:
|
||||
|
||||
1. **Linear regression** over the last *n* bars:
|
||||
- slope = (n·ΣxᵢYᵢ − Σxᵢ·ΣYᵢ) / (n·Σxᵢ² − (Σxᵢ)²)
|
||||
- intercept = (ΣYᵢ − slope·Σxᵢ) / n
|
||||
2. **Time Series Forecast** (regression endpoint):
|
||||
- TSF = slope × (n − 1) + intercept
|
||||
3. **Inertia**:
|
||||
- Inertia = source − TSF
|
||||
|
||||
Where x = 0 for the oldest bar, x = n−1 for the newest.
|
||||
|
||||
### Relationship to CFO
|
||||
|
||||
The Chande Forecast Oscillator normalizes the same residual:
|
||||
- CFO = 100 × (source − TSF) / source
|
||||
- Inertia = CFO × source / 100
|
||||
|
||||
## Interpretation
|
||||
|
||||
* **Positive values**: Price is above the regression line — bullish momentum, price exceeding expectations.
|
||||
* **Negative values**: Price is below the regression line — bearish momentum, price underperforming.
|
||||
* **Zero crossings**: Potential trend change signals as price crosses its forecast.
|
||||
* **Magnitude**: Larger absolute values indicate stronger deviation from trend.
|
||||
* **Divergence**: Price making new highs while Inertia declining suggests weakening trend.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Range | Description |
|
||||
|-----------|---------|-------|-------------|
|
||||
| Period | 20 | 1–500 | Lookback window for linear regression |
|
||||
| Source | Close | — | Price series to analyze |
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
// Streaming
|
||||
var inertia = new Inertia(period: 20);
|
||||
inertia.Update(new TValue(time, close));
|
||||
double value = inertia.Last.Value;
|
||||
|
||||
// Batch
|
||||
var results = Inertia.Batch(source, period: 20);
|
||||
|
||||
// Span (zero-allocation)
|
||||
Inertia.Batch(sourceSpan, outputSpan, period: 20);
|
||||
|
||||
// Event chaining
|
||||
var inertia = new Inertia(source, period: 20);
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
* **Not bounded**: Unlike CFO (percentage) or RSI (0–100), Inertia values are in price units and vary with price level. Comparing across instruments requires normalization.
|
||||
* **Linear assumption**: Assumes linear price behavior over the lookback period. Non-linear trends produce persistent non-zero residuals.
|
||||
* **Lag**: The regression line is fitted to past data; rapid reversals may not be captured quickly.
|
||||
* **Floating-point drift**: O(1) incremental computation may accumulate small errors over very long runs. Periodic resync mitigates this.
|
||||
|
||||
## References
|
||||
|
||||
- Dorsey, D. "The Relative Volatility Index." *Technical Analysis of Stocks & Commodities*, 1993.
|
||||
- Chande, T. "The New Technical Trader." John Wiley & Sons, 1994.
|
||||
- PineScript reference: `inertia.pine`
|
||||
Reference in New Issue
Block a user