Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+214
View File
@@ -0,0 +1,214 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class RwmaIndicatorTests
{
[Fact]
public void RwmaIndicator_Constructor_SetsDefaults()
{
var indicator = new RwmaIndicator();
Assert.Equal("RWMA - Range Weighted Moving Average", indicator.Name);
Assert.Equal(14, indicator.Period);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(14, indicator.MinHistoryDepths);
}
[Fact]
public void RwmaIndicator_ShortName_ReflectsPeriod()
{
var indicator = new RwmaIndicator { Period = 10 };
Assert.Equal("RWMA(10)", indicator.ShortName);
var indicatorDefault = new RwmaIndicator { Period = 14 };
Assert.Equal("RWMA(14)", indicatorDefault.ShortName);
}
[Fact]
public void RwmaIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new RwmaIndicator { Period = 10 };
Assert.Equal(10, indicator.MinHistoryDepths);
Assert.Equal(10, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void RwmaIndicator_Initialize_CreatesInternalRwma()
{
var indicator = new RwmaIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void RwmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new RwmaIndicator { Period = 5 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void RwmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new RwmaIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(30), 130, 140, 120, 135, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void RwmaIndicator_Value_TracksRangeWeightedAverage()
{
var indicator = new RwmaIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
var recordedValues = new List<double>();
for (int i = 0; i < 50; i++)
{
// Create varying price patterns with varying ranges
double open = 100 + i;
double high = open + 10 + (i % 5);
double low = open - 5;
double close = (i % 2 == 0) ? high - 1 : low + 1;
indicator.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
if (i > 0)
{
double val = indicator.LinesSeries[0].GetValue(0);
recordedValues.Add(val);
}
}
// RWMA should produce finite values
Assert.True(recordedValues.Count > 0, "Should have recorded values");
Assert.All(recordedValues, v => Assert.True(double.IsFinite(v)));
// RWMA values should be within price range (approximately)
double avgValue = recordedValues.Average();
Assert.True(avgValue > 90 && avgValue < 200, $"RWMA {avgValue} should be within reasonable price range");
}
[Fact]
public void RwmaIndicator_DifferentPeriods_ProduceDifferentResults()
{
var indicator5 = new RwmaIndicator { Period = 5 };
var indicator20 = new RwmaIndicator { Period = 20 };
indicator5.Initialize();
indicator20.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double open = 100 + i;
double high = open + 10;
double low = open - 5;
double close = open + 5;
indicator5.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
indicator20.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
indicator5.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator20.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val5 = indicator5.LinesSeries[0].GetValue(0);
double val20 = indicator20.LinesSeries[0].GetValue(0);
// Different periods should produce different results
// Shorter period responds faster to recent prices
Assert.NotEqual(val5, val20, 6);
}
[Fact]
public void RwmaIndicator_SlidingWindow_DropsOldValues()
{
var indicator = new RwmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add initial bars with constant price
for (int i = 0; i < 3; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double valueAtConstant = indicator.LinesSeries[0].GetValue(0);
// Add bars with higher prices - old low prices should drop out
for (int i = 3; i < 6; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 200, 210, 190, 200, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double valueAfterHigh = indicator.LinesSeries[0].GetValue(0);
// Value should have changed significantly as old bars dropped
Assert.True(valueAfterHigh > valueAtConstant + 50,
$"RWMA should increase as low-price bars drop out: {valueAtConstant} -> {valueAfterHigh}");
}
[Fact]
public void RwmaIndicator_VolatileBarsHaveMoreWeight()
{
var indicator = new RwmaIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add bars with varying ranges — volatile bar at close=50, quiet bar at close=150
// Volatile bar: range = 40
indicator.HistoricalData.AddBar(now, 50, 70, 30, 50, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Quiet bar: range = 2
indicator.HistoricalData.AddBar(now.AddMinutes(1), 150, 151, 149, 150, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
// RWMA should be close to 50 (the volatile bar) rather than 150
Assert.True(val < 60, $"RWMA {val} should be weighted toward volatile bar close (50)");
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// Quantower adapter for RWMA (Range Weighted Moving Average).
/// </summary>
[SkipLocalsInit]
public sealed class RwmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, 1, 10000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Rwma _rwma = null!;
private readonly LineSeries _series;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"RWMA({Period})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/rwma/Rwma.Quantower.cs";
public RwmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "RWMA - Range Weighted Moving Average";
Description = "Range Weighted Moving Average weights each bar's close by its price range (high - low), giving greater influence to volatile bars.";
_series = new LineSeries(name: "RWMA", color: Color.Cyan, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_rwma = new Rwma(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _rwma.Update(bar, args.IsNewBar());
_series.SetValue(result.Value, _rwma.IsHot, ShowColdValues);
}
}
+761
View File
@@ -0,0 +1,761 @@
namespace QuanTAlib.Tests;
public class RwmaTests
{
private readonly GBM _feed;
private readonly TBarSeries _bars;
public RwmaTests()
{
_feed = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
_bars = new TBarSeries();
for (int i = 0; i < 1000; i++)
{
_bars.Add(_feed.Next());
}
}
// ============ A) Constructor Validation ============
[Fact]
public void Constructor_DefaultPeriod_ShouldBe14()
{
var rwma = new Rwma();
Assert.Equal("Rwma(14)", rwma.Name);
}
[Fact]
public void Constructor_WithPeriod_ShouldSetName()
{
var rwma = new Rwma(10);
Assert.Equal("Rwma(10)", rwma.Name);
}
[Fact]
public void Constructor_ZeroPeriod_ShouldThrow()
{
var ex = Assert.Throws<ArgumentException>(() => new Rwma(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ShouldThrow()
{
var ex = Assert.Throws<ArgumentException>(() => new Rwma(-1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_Period1_ShouldNotThrow()
{
var rwma = new Rwma(1);
Assert.Equal("Rwma(1)", rwma.Name);
}
// ============ B) Basic Calculation ============
[Fact]
public void Update_ReturnsValidTValue()
{
var rwma = new Rwma(10);
var bar = _bars[0];
var result = rwma.Update(bar);
Assert.NotEqual(default, result);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_FirstBar_ShouldBeClosePrice()
{
var rwma = new Rwma(10);
var bar = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
var result = rwma.Update(bar);
// RWMA of first bar: range=15-8=7, sumCR=12*7=84, sumR=7, RWMA=84/7=12
Assert.Equal(12.0, result.Value, 10);
}
[Fact]
public void Update_MultipleBarsSamePrice_ShouldReturnSameRwma()
{
var rwma = new Rwma(10);
// All bars have same close and same range
var bar1 = new TBar(DateTime.UtcNow, 95, 105, 95, 100, 100);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 95, 105, 95, 100, 200);
var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 95, 105, 95, 100, 300);
rwma.Update(bar1);
rwma.Update(bar2);
var result = rwma.Update(bar3);
// All closes = 100, all ranges = 10, so RWMA = (100*10 + 100*10 + 100*10) / (10+10+10) = 100
Assert.Equal(100.0, result.Value, 10);
}
[Fact]
public void Update_RangeWeighting_Works()
{
var rwma = new Rwma(10);
// Bar 1: close=10, range=2 (high=11, low=9)
// Bar 2: close=20, range=6 (high=23, low=17)
// RWMA = (10*2 + 20*6) / (2+6) = (20 + 120) / 8 = 17.5
var bar1 = new TBar(DateTime.UtcNow, 10, 11, 9, 10, 100);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 23, 17, 20, 100);
rwma.Update(bar1);
var result = rwma.Update(bar2);
Assert.Equal(17.5, result.Value, 10);
}
[Fact]
public void Update_HighRangeBar_HasMoreInfluence()
{
var rwma = new Rwma(10);
// Bar 1: close=10, high range (range=20)
var bar1 = new TBar(DateTime.UtcNow, 10, 20, 0, 10, 100);
// Bar 2: close=20, low range (range=2)
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 21, 19, 20, 100);
rwma.Update(bar1);
var result = rwma.Update(bar2);
// RWMA = (10*20 + 20*2) / (20+2) = (200+40)/22 = 10.909...
double expected = (10.0 * 20.0 + 20.0 * 2.0) / (20.0 + 2.0);
Assert.Equal(expected, result.Value, 10);
// Should be closer to 10 (the high-range bar) than 20
Assert.True(result.Value < 15, "RWMA should be weighted toward high-range bar's close");
}
[Fact]
public void Update_SlidingWindow_ShouldDropOldValues()
{
var rwma = new Rwma(2);
// Period = 2, so only last 2 bars count
// Bar 1: close=10, range=4 (h=12, l=8)
var bar1 = new TBar(DateTime.UtcNow, 10, 12, 8, 10, 100);
rwma.Update(bar1);
// Bar 2: close=20, range=4 (h=22, l=18)
// RWMA = (10*4 + 20*4) / (4+4) = 120/8 = 15
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 22, 18, 20, 100);
rwma.Update(bar2);
Assert.Equal(15.0, rwma.Last.Value, 10);
// Bar 3: close=30, range=4 (h=32, l=28)
// Now bar1 drops out: RWMA = (20*4 + 30*4) / (4+4) = 200/8 = 25
var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 30, 32, 28, 30, 100);
var result = rwma.Update(bar3);
Assert.Equal(25.0, result.Value, 10);
}
[Fact]
public void Update_ZeroRange_DegeneratesToClose()
{
var rwma = new Rwma(10);
// All bars have zero range (high == low == close)
var bar1 = new TBar(DateTime.UtcNow, 50, 50, 50, 50, 100);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 60, 60, 60, 60, 100);
var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 70, 70, 70, 70, 100);
rwma.Update(bar1);
rwma.Update(bar2);
var result = rwma.Update(bar3);
// All ranges = 0, so RWMA degenerates to current close = 70
Assert.Equal(70.0, result.Value, 10);
}
// ============ C) State + Bar Correction (isNew) ============
[Fact]
public void IsHot_AfterPeriodBars_ShouldBeTrue()
{
var rwma = new Rwma(10);
Assert.False(rwma.IsHot);
for (int i = 0; i < 9; i++)
{
rwma.Update(_bars[i]);
Assert.False(rwma.IsHot);
}
rwma.Update(_bars[9]);
Assert.True(rwma.IsHot);
}
[Fact]
public void WarmupPeriod_ShouldMatchPeriod()
{
var rwma = new Rwma(14);
Assert.Equal(14, rwma.WarmupPeriod);
}
[Fact]
public void Update_IsNewTrue_ShouldAdvanceState()
{
var rwma = new Rwma(10);
var bar1 = new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 25, 15, 20, 100);
rwma.Update(bar1, isNew: true);
var result1 = rwma.Last.Value;
rwma.Update(bar2, isNew: true);
var result2 = rwma.Last.Value;
Assert.NotEqual(result1, result2);
}
[Fact]
public void Update_IsNewFalse_ShouldRollback()
{
var rwma = new Rwma(10);
var bar1 = new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 25, 15, 20, 100);
var bar2Updated = new TBar(DateTime.UtcNow.AddMinutes(1), 15, 18, 12, 15, 100);
rwma.Update(bar1, isNew: true);
rwma.Update(bar2, isNew: true);
var afterBar2 = rwma.Last.Value;
// Correct bar2 with updated values
rwma.Update(bar2Updated, isNew: false);
var afterCorrection = rwma.Last.Value;
Assert.NotEqual(afterBar2, afterCorrection);
}
[Fact]
public void Update_IterativeCorrections_ShouldRestoreState()
{
var rwma = new Rwma(10);
// Process first 10 bars
for (int i = 0; i < 10; i++)
{
rwma.Update(_bars[i], isNew: true);
}
_ = rwma.Last.Value;
// Process bar 11
rwma.Update(_bars[10], isNew: true);
var valueAfter11 = rwma.Last.Value;
// Correct bar 11 multiple times with same data
for (int i = 0; i < 5; i++)
{
rwma.Update(_bars[10], isNew: false);
}
var valueAfterCorrections = rwma.Last.Value;
// Should get same result as after first processing of bar 11
Assert.Equal(valueAfter11, valueAfterCorrections, 10);
}
[Fact]
public void Reset_ShouldClearState()
{
var rwma = new Rwma(10);
for (int i = 0; i < 100; i++)
{
rwma.Update(_bars[i]);
}
Assert.True(rwma.IsHot);
rwma.Reset();
Assert.False(rwma.IsHot);
Assert.Equal(default, rwma.Last);
}
// ============ D) Warmup/Convergence ============
[Fact]
public void IsHot_FlipsExactlyAtPeriod()
{
var rwma = new Rwma(5);
for (int i = 0; i < 4; i++)
{
rwma.Update(_bars[i]);
Assert.False(rwma.IsHot, $"IsHot should be false at bar {i}");
}
rwma.Update(_bars[4]);
Assert.True(rwma.IsHot, "IsHot should be true at bar 4 (5th bar)");
}
[Fact]
public void WarmupPeriod_DependsOnPeriod()
{
Assert.Equal(5, new Rwma(5).WarmupPeriod);
Assert.Equal(20, new Rwma(20).WarmupPeriod);
Assert.Equal(100, new Rwma(100).WarmupPeriod);
}
// ============ E) Robustness (NaN/Infinity) ============
[Fact]
public void Update_NaN_ShouldUseLastValidValue()
{
var rwma = new Rwma(10);
// First bar establishes valid values
var bar1 = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
rwma.Update(bar1);
// Second bar with NaN should use last valid
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
var result = rwma.Update(bar2);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Infinity_ShouldUseLastValidValue()
{
var rwma = new Rwma(10);
var bar1 = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
rwma.Update(bar1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
var result = rwma.Update(bar2);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_BatchNaN_ShouldRemainFinite()
{
var rwma = new Rwma(10);
// Establish valid state
for (int i = 0; i < 20; i++)
{
rwma.Update(_bars[i]);
}
// Send multiple NaN bars
for (int i = 0; i < 5; i++)
{
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(20 + i),
double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
var result = rwma.Update(nanBar);
Assert.True(double.IsFinite(result.Value), $"NaN bar {i} produced non-finite result");
}
}
// ============ F) Consistency (4 API modes) ============
[Fact]
public void Streaming_ShouldMatchBatch()
{
int period = 14;
// Streaming
var rwma = new Rwma(period);
var streamingResults = new List<double>();
foreach (var bar in _bars)
{
streamingResults.Add(rwma.Update(bar).Value);
}
// Batch
var batchResult = Rwma.Batch(_bars, period);
// Compare last 100 values
for (int i = _bars.Count - 100; i < _bars.Count; i++)
{
Assert.Equal(batchResult.Values[i], streamingResults[i], 10);
}
}
[Fact]
public void Batch_TBarSeries_ShouldMatchSpan()
{
int period = 14;
var batchResult = Rwma.Batch(_bars, period);
var close = _bars.Close.Values.ToArray();
var high = _bars.High.Values.ToArray();
var low = _bars.Low.Values.ToArray();
var spanOutput = new double[_bars.Count];
Rwma.Batch(close, high, low, spanOutput, period);
for (int i = 0; i < _bars.Count; i++)
{
Assert.Equal(batchResult.Values[i], spanOutput[i], 12);
}
}
[Fact]
public void Eventing_ShouldMatchStreaming()
{
int period = 14;
// Streaming
var rwma1 = new Rwma(period);
var streamingResults = new List<double>();
foreach (var bar in _bars)
{
streamingResults.Add(rwma1.Update(bar).Value);
}
// Event-based
var rwma2 = new Rwma(period);
var eventResults = new List<double>();
rwma2.Pub += (object? sender, in TValueEventArgs args) => eventResults.Add(args.Value.Value);
foreach (var bar in _bars)
{
rwma2.Update(bar);
}
Assert.Equal(streamingResults.Count, eventResults.Count);
for (int i = 0; i < streamingResults.Count; i++)
{
Assert.Equal(streamingResults[i], eventResults[i], 12);
}
}
// ============ G) Span API Tests ============
[Fact]
public void Batch_Span_MismatchedLengths_ShouldThrow()
{
var close = new double[100];
var high = new double[99]; // Mismatched
var low = new double[100];
var output = new double[100];
var ex = Assert.Throws<ArgumentException>(() => Rwma.Batch(close, high, low, output, 10));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_Span_OutputLengthMismatch_ShouldThrow()
{
var close = new double[100];
var high = new double[100];
var low = new double[100];
var output = new double[50]; // Mismatched
var ex = Assert.Throws<ArgumentException>(() => Rwma.Batch(close, high, low, output, 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_Span_ZeroPeriod_ShouldThrow()
{
var close = new double[100];
var high = new double[100];
var low = new double[100];
var output = new double[100];
var ex = Assert.Throws<ArgumentException>(() => Rwma.Batch(close, high, low, output, 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_Span_NegativePeriod_ShouldThrow()
{
var close = new double[100];
var high = new double[100];
var low = new double[100];
var output = new double[100];
var ex = Assert.Throws<ArgumentException>(() => Rwma.Batch(close, high, low, output, -1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_Span_NaN_ShouldNotPropagate()
{
var close = new double[] { 10, 20, double.NaN, 40, 50 };
var high = new double[] { 15, 25, double.NaN, 45, 55 };
var low = new double[] { 5, 15, double.NaN, 35, 45 };
var output = new double[5];
Rwma.Batch(close, high, low, output, 3);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite: {output[i]}");
}
}
[Fact]
public void Batch_Span_LargeData_ShouldNotOverflow()
{
// Test with period > StackallocThreshold (256)
int period = 300;
int len = 500;
var close = new double[len];
var high = new double[len];
var low = new double[len];
var output = new double[len];
for (int i = 0; i < len; i++)
{
close[i] = 100 + i;
high[i] = 100 + i + 5;
low[i] = 100 + i - 5;
}
Rwma.Batch(close, high, low, output, period);
for (int i = 0; i < len; i++)
{
Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
}
}
// ============ H) Chainability / Events ============
[Fact]
public void Pub_ShouldFireOnUpdate()
{
var rwma = new Rwma(10);
int eventCount = 0;
rwma.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
rwma.Update(_bars[0]);
rwma.Update(_bars[1]);
Assert.Equal(2, eventCount);
}
[Fact]
public void Pub_EventArgs_ShouldContainCorrectValue()
{
var rwma = new Rwma(10);
TValue? lastEventValue = null;
rwma.Pub += (object? sender, in TValueEventArgs args) => lastEventValue = args.Value;
var result = rwma.Update(_bars[0]);
Assert.NotNull(lastEventValue);
Assert.Equal(result.Value, lastEventValue.Value.Value, 12);
}
// ============ TValue Input Tests ============
[Fact]
public void Update_TValue_ShouldWork()
{
var rwma = new Rwma(10);
var input = new TValue(DateTime.UtcNow, 100.0);
var result = rwma.Update(input);
// With TValue, high=low=close → range=0, degenerates to close
Assert.Equal(100.0, result.Value, 10);
}
[Fact]
public void Update_TValue_MultipleInputs_DegeneratesToClose()
{
var rwma = new Rwma(10);
// TValue input: range always 0, so always degenerates to current close
rwma.Update(new TValue(DateTime.UtcNow, 100.0));
var result = rwma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 200.0));
// All ranges 0 → fallback to current close = 200
Assert.Equal(200.0, result.Value, 10);
}
// ============ Batch/Series Tests ============
[Fact]
public void Update_TBarSeries_ShouldReturnTSeries()
{
var rwma = new Rwma(10);
var result = rwma.Update(_bars);
Assert.NotNull(result);
Assert.Equal(_bars.Count, result.Count);
}
[Fact]
public void Batch_Static_ShouldReturnTSeries()
{
var result = Rwma.Batch(_bars, 10);
Assert.NotNull(result);
Assert.Equal(_bars.Count, result.Count);
}
[Fact]
public void Batch_Static_WithDifferentPeriods_ShouldWork()
{
var result14 = Rwma.Batch(_bars, 14);
var result50 = Rwma.Batch(_bars, 50);
Assert.NotNull(result14);
Assert.NotNull(result50);
Assert.Equal(_bars.Count, result14.Count);
Assert.Equal(_bars.Count, result50.Count);
}
// ============ TSeries Calculate Tests ============
[Fact]
public void Calculate_Static_ShouldReturnTSeriesAndIndicator()
{
var (results, indicator) = Rwma.Calculate(_bars, 14);
Assert.NotNull(results);
Assert.Equal(_bars.Count, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void Batch_TSeries_ShouldWork()
{
var sourceSeries = _bars.Close;
var result = Rwma.Batch(sourceSeries, 20);
Assert.NotNull(result);
Assert.Equal(sourceSeries.Count, result.Count);
}
// ============ Prime Tests ============
[Fact]
public void Prime_ShouldInitializeState()
{
var rwma = new Rwma(10);
rwma.Prime(_bars);
Assert.True(rwma.IsHot);
Assert.True(double.IsFinite(rwma.Last.Value));
}
[Fact]
public void Prime_ThenUpdate_ShouldContinueCorrectly()
{
var rwma1 = new Rwma(10);
var rwma2 = new Rwma(10);
// rwma1: process all bars
for (int i = 0; i < 100; i++)
{
rwma1.Update(_bars[i]);
}
// rwma2: prime with first 50, then stream remaining
var primeBars = new TBarSeries();
for (int i = 0; i < 50; i++)
{
primeBars.Add(_bars[i]);
}
rwma2.Prime(primeBars);
for (int i = 50; i < 100; i++)
{
rwma2.Update(_bars[i]);
}
// Both should produce the same result
Assert.Equal(rwma1.Last.Value, rwma2.Last.Value, 10);
}
// ============ Algorithm-Specific Tests ============
[Fact]
public void RangeWeighting_VolatileBarHasMoreWeight()
{
var rwma = new Rwma(10);
// Bar with large range (volatile) at close=50
var volatileBar = new TBar(DateTime.UtcNow, 50, 70, 30, 50, 100); // range=40
// Bar with small range (quiet) at close=100
var quietBar = new TBar(DateTime.UtcNow.AddMinutes(1), 100, 101, 99, 100, 100); // range=2
rwma.Update(volatileBar);
var result = rwma.Update(quietBar);
// RWMA = (50*40 + 100*2) / (40+2) = (2000+200)/42 = 52.38...
double expected = (50.0 * 40.0 + 100.0 * 2.0) / 42.0;
Assert.Equal(expected, result.Value, 10);
// Should be much closer to 50 than 100
Assert.True(result.Value < 60, "RWMA should strongly lean toward the volatile bar's close");
}
[Fact]
public void StablePrice_ConstantRange_ShouldReturnSma()
{
var rwma = new Rwma(5);
// When all bars have the same range, RWMA reduces to SMA of closes
// because weights are all equal
var now = DateTime.UtcNow;
double[] closes = { 10, 20, 30, 40, 50 };
for (int i = 0; i < 5; i++)
{
// All bars have range = 10
var bar = new TBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i], 100);
rwma.Update(bar);
}
// When all ranges equal, RWMA = SMA = (10+20+30+40+50)/5 = 30
Assert.Equal(30.0, rwma.Last.Value, 10);
}
[Fact]
public void Period1_ShouldReturnClose()
{
var rwma = new Rwma(1);
var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 100);
var result = rwma.Update(bar);
// Period 1: only current bar, RWMA = close * range / range = close
Assert.Equal(55.0, result.Value, 10);
}
[Fact]
public void ConvexCombination_NeverExceedsPriceRange()
{
var rwma = new Rwma(20);
var results = new List<double>();
for (int i = 0; i < 100; i++)
{
results.Add(rwma.Update(_bars[i]).Value);
}
// Find min/max close in last 20 bars for the last few results
for (int i = 80; i < 100; i++)
{
double minClose = double.MaxValue;
double maxClose = double.MinValue;
for (int j = i - 19; j <= i; j++)
{
double c = _bars[j].Close;
if (c < minClose)
{
minClose = c;
}
if (c > maxClose)
{
maxClose = c;
}
}
// RWMA is a convex combination — should be within [minClose, maxClose]
Assert.True(results[i] >= minClose - 1e-9 && results[i] <= maxClose + 1e-9,
$"RWMA at {i} ({results[i]}) should be within [{minClose}, {maxClose}]");
}
}
}
@@ -0,0 +1,314 @@
namespace QuanTAlib.Tests;
public class RwmaValidationTests
{
private readonly ValidationTestData _data;
public RwmaValidationTests()
{
_data = new ValidationTestData();
}
// ============ External Library Validation ============
// RWMA is not available in Skender, TA-Lib, Tulip, or Ooples.
// Validation focuses on internal consistency and algorithm correctness.
[Fact]
public void Rwma_NotAvailable_Skender()
{
Assert.True(true, "RWMA is not available in Skender.Stock.Indicators");
}
[Fact]
public void Rwma_NotAvailable_TaLib()
{
Assert.True(true, "RWMA is not available in TA-Lib");
}
[Fact]
public void Rwma_NotAvailable_Tulip()
{
Assert.True(true, "RWMA is not available in Tulip");
}
[Fact]
public void Rwma_NotAvailable_Ooples()
{
Assert.True(true, "RWMA is not available in OoplesFinance");
}
// ============ Internal Consistency Tests ============
[Fact]
public void Rwma_Streaming_Matches_Batch()
{
int period = 14;
// Streaming
var rwma = new Rwma(period);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(rwma.Update(bar).Value);
}
// Batch
var batchResult = Rwma.Batch(_data.Bars, period);
var batchValues = batchResult.Values.ToArray();
ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-10);
}
[Fact]
public void Rwma_Span_Matches_Streaming()
{
int period = 14;
// Streaming
var rwma = new Rwma(period);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(rwma.Update(bar).Value);
}
// Span
var close = _data.Bars.Close.Values.ToArray();
var high = _data.Bars.High.Values.ToArray();
var low = _data.Bars.Low.Values.ToArray();
var spanValues = new double[close.Length];
Rwma.Batch(close, high, low, spanValues, period);
ValidationHelper.VerifyData(streamingValues.ToArray(), spanValues, 0, 100, 1e-10);
}
[Fact]
public void Rwma_Batch_Matches_Span()
{
int period = 14;
// Batch
var batchResult = Rwma.Batch(_data.Bars, period);
var batchValues = batchResult.Values.ToArray();
// Span
var close = _data.Bars.Close.Values.ToArray();
var high = _data.Bars.High.Values.ToArray();
var low = _data.Bars.Low.Values.ToArray();
var spanValues = new double[close.Length];
Rwma.Batch(close, high, low, spanValues, period);
// Batch and Span use identical code path, should match exactly
ValidationHelper.VerifyData(batchValues, spanValues, 0, 100, 1e-12);
}
// ============ Algorithm Correctness Tests ============
[Fact]
public void Rwma_Algorithm_Correctness_ManualCalculation()
{
// Manual calculation to verify algorithm correctness
var bars = new TBarSeries();
// Bar 0: close=10, high=15, low=5 → range=10
// Bar 1: close=20, high=24, low=18 → range=6
// Bar 2: close=30, high=35, low=25 → range=10
bars.Add(new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100));
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(1), 20, 24, 18, 20, 100));
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(2), 30, 35, 25, 30, 100));
var rwma = new Rwma(10); // Period larger than data
var results = new List<double>();
foreach (var bar in bars)
{
results.Add(rwma.Update(bar).Value);
}
// Bar 0: RWMA = 10*10 / 10 = 10
Assert.Equal(10.0, results[0], 6);
// Bar 1: RWMA = (10*10 + 20*6) / (10+6) = (100+120)/16 = 13.75
double expectedBar1 = (10.0 * 10.0 + 20.0 * 6.0) / 16.0;
Assert.Equal(expectedBar1, results[1], 6);
// Bar 2: RWMA = (10*10 + 20*6 + 30*10) / (10+6+10) = (100+120+300)/26 = 20.0
double expectedBar2 = (10.0 * 10.0 + 20.0 * 6.0 + 30.0 * 10.0) / 26.0;
Assert.Equal(expectedBar2, results[2], 6);
}
[Fact]
public void Rwma_Algorithm_Correctness_SlidingWindow()
{
// Verify sliding window drops old values correctly
var rwma = new Rwma(2); // Period = 2
// Bar 0: close=10, range=10 (h=15, l=5)
rwma.Update(new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100));
Assert.Equal(10.0, rwma.Last.Value, 6);
// Bar 1: close=20, range=6 (h=23, l=17)
// RWMA = (10*10 + 20*6) / (10+6) = 220/16 = 13.75
rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 20, 23, 17, 20, 100));
Assert.Equal(13.75, rwma.Last.Value, 6);
// Bar 2: close=30, range=10 (h=35, l=25)
// Now bar0 drops out: RWMA = (20*6 + 30*10) / (6+10) = (120+300)/16 = 26.25
rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(2), 30, 35, 25, 30, 100));
Assert.Equal(26.25, rwma.Last.Value, 6);
}
[Fact]
public void Rwma_Algorithm_Correctness_RangeWeighting()
{
// Verify range weighting: high-range bars have more influence
var rwma = new Rwma(10);
// Two bars: one with high range at low price, one with low range at high price
rwma.Update(new TBar(DateTime.UtcNow, 10, 60, 10, 10, 100)); // range=50
var result = rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 100, 101, 99, 100, 100)); // range=2
// RWMA = (10*50 + 100*2) / (50+2) = (500+200)/52 = 13.46...
double expected = (10.0 * 50.0 + 100.0 * 2.0) / 52.0;
Assert.Equal(expected, result.Value, 6);
// RWMA should be much closer to 10 than to 100
Assert.True(result.Value < 20, "RWMA should be weighted toward high-range price");
}
[Fact]
public void Rwma_DifferentPeriods_ProduceDifferentResults()
{
var rwma10 = new Rwma(10);
var rwma20 = new Rwma(20);
var rwma50 = new Rwma(50);
var results10 = new List<double>();
var results20 = new List<double>();
var results50 = new List<double>();
foreach (var bar in _data.Bars)
{
results10.Add(rwma10.Update(bar).Value);
results20.Add(rwma20.Update(bar).Value);
results50.Add(rwma50.Update(bar).Value);
}
// After sufficient bars, different periods should produce different results
int checkIndex = 60;
bool anyDifferent = Math.Abs(results10[checkIndex] - results20[checkIndex]) > 1e-6 ||
Math.Abs(results20[checkIndex] - results50[checkIndex]) > 1e-6;
Assert.True(anyDifferent, "Different periods should produce different RWMA values");
}
[Fact]
public void Rwma_StableWithConstantPrice()
{
// RWMA should remain stable when close price is constant (regardless of range)
var rwma = new Rwma(10);
var results = new List<double>();
for (int i = 0; i < 100; i++)
{
// Close always 50, but varying ranges
double range = 5 + (i % 10);
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 50, 50 + range, 50 - range, 50, 1000);
results.Add(rwma.Update(bar).Value);
}
// All RWMA values should be 50 (constant close, varying range)
for (int i = 0; i < results.Count; i++)
{
Assert.Equal(50.0, results[i], 10);
}
}
[Fact]
public void Rwma_ZeroRange_DegeneratesToCurrentClose()
{
// When all ranges are zero, RWMA should return current close
var rwma = new Rwma(10);
for (int i = 0; i < 20; i++)
{
double close = 100 + i;
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), close, close, close, close, 100);
var result = rwma.Update(bar);
Assert.Equal(close, result.Value, 10);
}
}
[Fact]
public void Rwma_EqualRanges_ReducesToSma()
{
// When all ranges are equal, RWMA = SMA of closes
var rwma = new Rwma(3);
// Three bars with equal range (10) but different closes
rwma.Update(new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100)); // range=10
rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 20, 25, 15, 20, 100)); // range=10
rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(2), 30, 35, 25, 30, 100)); // range=10
// RWMA = (10*10 + 20*10 + 30*10) / (10+10+10) = 600/30 = 20 = SMA(10,20,30)
Assert.Equal(20.0, rwma.Last.Value, 10);
}
[Fact]
public void Rwma_ResponsiveToPriceChanges()
{
// Shorter period RWMA should track price more closely
var rwmaShort = new Rwma(5);
var rwmaLong = new Rwma(50);
for (int i = 0; i < 100; i++)
{
double close = i;
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), close, close + 5, close - 5, close, 1000);
rwmaShort.Update(bar);
rwmaLong.Update(bar);
}
// Short period RWMA should be closer to current price (99)
double shortDiff = Math.Abs(rwmaShort.Last.Value - 99);
double longDiff = Math.Abs(rwmaLong.Last.Value - 99);
Assert.True(shortDiff < longDiff, "Short period RWMA should track price more closely");
}
[Fact]
public void Rwma_ConvexCombination_OutputWithinPriceRange()
{
// RWMA is a convex combination, so output must be within [min, max] of closes in window
var rwma = new Rwma(10);
var closes = new List<double>();
var results = new List<double>();
foreach (var bar in _data.Bars)
{
closes.Add(bar.Close);
results.Add(rwma.Update(bar).Value);
}
// Check after warmup
for (int i = 10; i < 200; i++)
{
double minClose = double.MaxValue;
double maxClose = double.MinValue;
for (int j = i - 9; j <= i; j++)
{
if (closes[j] < minClose)
{
minClose = closes[j];
}
if (closes[j] > maxClose)
{
maxClose = closes[j];
}
}
Assert.True(results[i] >= minClose - 1e-9 && results[i] <= maxClose + 1e-9,
$"RWMA at {i} ({results[i]}) should be within [{minClose}, {maxClose}]");
}
}
}
+479
View File
@@ -0,0 +1,479 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RWMA: Range Weighted Moving Average
/// </summary>
/// <remarks>
/// Weights each bar's contribution by its price range (high - low), giving
/// greater influence to volatile bars and less to narrow-range bars.
/// <c>RWMA = Σ(close_i × range_i) / Σ(range_i)</c> where <c>range_i = max(high_i - low_i, 0)</c>.
///
/// Requires TBar (OHLC) inputs. When all bars have zero range the output
/// degenerates to the current close price.
///
/// O(1) per bar via circular buffers with running sums.
/// </remarks>
/// <seealso href="Rwma.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Rwma : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(double SumCR, double SumR, int Index, int Head, int Count, int SyncCounter)
{
public static State New() => new() { SumCR = 0, SumR = 0, Index = 0, Head = 0, Count = 0, SyncCounter = 0 };
}
/// <summary>
/// Resync interval to limit floating-point drift in running sums.
/// Full recalculation every N bars.
/// </summary>
private const int ResyncInterval = 1000;
private readonly int _period;
private readonly double[] _closeBuffer;
private readonly double[] _rangeBuffer;
private State _state;
private State _p_state;
private double _lastValidClose;
private double _lastValidHigh;
private double _lastValidLow;
private double _p_lastValidClose;
private double _p_lastValidHigh;
private double _p_lastValidLow;
private double _p_bufferClose;
private double _p_bufferRange;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event TValuePublishedHandler? Pub;
/// <summary>
/// Current RWMA value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the indicator has processed at least Period bars.
/// </summary>
public bool IsHot => _state.Count >= _period;
/// <summary>
/// Warmup period equals the specified period.
/// </summary>
#pragma warning disable S2325
public int WarmupPeriod => _period;
#pragma warning restore S2325
/// <summary>
/// Creates a new RWMA indicator.
/// </summary>
/// <param name="period">Lookback period. Must be >= 1.</param>
public Rwma(int period = 14)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_period = period;
_closeBuffer = new double[period];
_rangeBuffer = new double[period];
_state = State.New();
_p_state = State.New();
Name = $"Rwma({period})";
}
/// <summary>
/// Resets the indicator state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_state = State.New();
_p_state = State.New();
Array.Clear(_closeBuffer);
Array.Clear(_rangeBuffer);
_lastValidClose = 0;
_lastValidHigh = 0;
_lastValidLow = 0;
_p_lastValidClose = 0;
_p_lastValidHigh = 0;
_p_lastValidLow = 0;
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double GetValidValue(double input, ref double lastValid)
{
if (double.IsFinite(input))
{
lastValid = input;
return input;
}
return lastValid;
}
/// <summary>
/// Recalculates running sums from buffer to eliminate accumulated floating-point drift.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ResyncRunningTotals(ref State s)
{
double sumCR = 0;
double sumR = 0;
for (int i = 0; i < _period; i++)
{
double c = _closeBuffer[i];
double r = _rangeBuffer[i];
sumCR += c * r;
sumR += r;
}
s.SumCR = sumCR;
s.SumR = sumR;
}
/// <summary>
/// Updates RWMA with a TBar input (uses close, high, low).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public TValue Update(TBar input, bool isNew = true)
{
return UpdateInternal(input.Time, input.Close, input.High, input.Low, isNew);
}
/// <summary>
/// Updates RWMA with a TValue input (uses value as close, range = 0).
/// With zero range all bars have equal weight, degenerating to SMA.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public TValue Update(TValue input, bool isNew = true)
{
// When given a single value, high = low = close → range = 0
// All weights are 0, so fallback to current close
return UpdateInternal(input.Time, input.Value, input.Value, input.Value, isNew);
}
/// <summary>
/// Calculates RWMA for an entire bar series.
/// </summary>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
var t = new List<long>(source.Count);
var v = new List<double>(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], isNew: true);
t.Add(val.Time);
v.Add(val.Value);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private TValue UpdateInternal(long time, double close, double high, double low, bool isNew)
{
var s = _state;
if (isNew)
{
_p_state = _state;
_p_lastValidClose = _lastValidClose;
_p_lastValidHigh = _lastValidHigh;
_p_lastValidLow = _lastValidLow;
_p_bufferClose = _closeBuffer[s.Head];
_p_bufferRange = _rangeBuffer[s.Head];
}
else
{
s = _p_state;
_state = _p_state;
_lastValidClose = _p_lastValidClose;
_lastValidHigh = _p_lastValidHigh;
_lastValidLow = _p_lastValidLow;
_closeBuffer[s.Head] = _p_bufferClose;
_rangeBuffer[s.Head] = _p_bufferRange;
}
double currentClose = GetValidValue(close, ref _lastValidClose);
double currentHigh = GetValidValue(high, ref _lastValidHigh);
double currentLow = GetValidValue(low, ref _lastValidLow);
double currentRange = Math.Max(currentHigh - currentLow, 0.0);
// Remove old values from circular buffer
double oldClose = _closeBuffer[s.Head];
double oldRange = _rangeBuffer[s.Head];
if (s.Count >= _period)
{
s.SumCR -= oldClose * oldRange;
s.SumR -= oldRange;
}
// Add new values
s.SumCR += currentClose * currentRange;
s.SumR += currentRange;
// Store in circular buffer
_closeBuffer[s.Head] = currentClose;
_rangeBuffer[s.Head] = currentRange;
// Advance head pointer
s.Head = (s.Head + 1) % _period;
if (isNew)
{
s.Index++;
if (s.Count < _period)
{
s.Count++;
}
// Periodic resync to limit floating-point drift
s.SyncCounter++;
if (s.SyncCounter >= ResyncInterval && s.Count >= _period)
{
s.SyncCounter = 0;
ResyncRunningTotals(ref s);
}
}
// Calculate RWMA: Σ(close × range) / Σ(range)
// When all ranges are zero, fall back to current close
double rwma = s.SumR > double.Epsilon ? s.SumCR / s.SumR : currentClose;
_state = s;
Last = new TValue(time, rwma);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Initializes the indicator state using the provided bar series history.
/// </summary>
public void Prime(TBarSeries source)
{
Reset();
if (source.Count == 0)
{
return;
}
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
/// <summary>
/// Static calculation returning TSeries from TBarSeries.
/// </summary>
public static TSeries Batch(TBarSeries source, int period = 14)
{
if (source.Count == 0)
{
return [];
}
var t = source.Open.Times.ToArray();
var v = new double[source.Count];
Batch(source.Close.Values, source.High.Values, source.Low.Values, v, period);
return new TSeries(t, v);
}
/// <summary>
/// Static calculation for TSeries (single-valued, range = 0 → degenerates to SMA).
/// </summary>
public static TSeries Batch(TSeries source, int period = 14)
{
if (source.Count == 0)
{
return [];
}
var t = source.Times.ToArray();
var v = new double[source.Count];
// No high/low available — use close for high and low → range = 0, so always fallback to close
Batch(source.Values, source.Values, source.Values, v, period);
return new TSeries(t, v);
}
/// <summary>
/// Zero-allocation span-based calculation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> close, ReadOnlySpan<double> high, ReadOnlySpan<double> low, Span<double> output, int period = 14)
{
if (close.Length != high.Length || close.Length != low.Length)
{
throw new ArgumentException("Close, High, and Low spans must be of the same length", nameof(high));
}
if (close.Length != output.Length)
{
throw new ArgumentException("Output span must be of the same length as input", nameof(output));
}
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
int len = close.Length;
if (len == 0)
{
return;
}
const int StackallocThreshold = 256;
double[]? rentedClose = null;
double[]? rentedRange = null;
scoped Span<double> closeBuffer;
scoped Span<double> rangeBuffer;
if (period <= StackallocThreshold)
{
closeBuffer = stackalloc double[period];
rangeBuffer = stackalloc double[period];
}
else
{
rentedClose = System.Buffers.ArrayPool<double>.Shared.Rent(period);
rentedRange = System.Buffers.ArrayPool<double>.Shared.Rent(period);
closeBuffer = rentedClose.AsSpan(0, period);
rangeBuffer = rentedRange.AsSpan(0, period);
}
try
{
closeBuffer.Clear();
rangeBuffer.Clear();
double sumCR = 0;
double sumR = 0;
double lastValidClose = 0;
double lastValidHigh = 0;
double lastValidLow = 0;
int head = 0;
int count = 0;
// Find first valid values
for (int k = 0; k < len; k++)
{
if (double.IsFinite(close[k])) { lastValidClose = close[k]; break; }
}
for (int k = 0; k < len; k++)
{
if (double.IsFinite(high[k])) { lastValidHigh = high[k]; break; }
}
for (int k = 0; k < len; k++)
{
if (double.IsFinite(low[k])) { lastValidLow = low[k]; break; }
}
int syncCounter = 0;
for (int i = 0; i < len; i++)
{
double currentClose = double.IsFinite(close[i]) ? close[i] : lastValidClose;
double currentHigh = double.IsFinite(high[i]) ? high[i] : lastValidHigh;
double currentLow = double.IsFinite(low[i]) ? low[i] : lastValidLow;
if (double.IsFinite(close[i]))
{
lastValidClose = close[i];
}
if (double.IsFinite(high[i]))
{
lastValidHigh = high[i];
}
if (double.IsFinite(low[i]))
{
lastValidLow = low[i];
}
double currentRange = Math.Max(currentHigh - currentLow, 0.0);
// Remove old values from circular buffer
double oldClose = closeBuffer[head];
double oldRange = rangeBuffer[head];
if (count >= period)
{
sumCR -= oldClose * oldRange;
sumR -= oldRange;
}
// Add new values
sumCR += currentClose * currentRange;
sumR += currentRange;
// Store in circular buffer
closeBuffer[head] = currentClose;
rangeBuffer[head] = currentRange;
head = (head + 1) % period;
if (count < period)
{
count++;
}
// Periodic resync
syncCounter++;
if (syncCounter >= ResyncInterval && count >= period)
{
syncCounter = 0;
sumCR = 0;
sumR = 0;
for (int j = 0; j < period; j++)
{
sumCR += closeBuffer[j] * rangeBuffer[j];
sumR += rangeBuffer[j];
}
}
output[i] = sumR > double.Epsilon ? sumCR / sumR : currentClose;
}
}
finally
{
if (rentedClose != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedClose);
}
if (rentedRange != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedRange);
}
}
}
public static (TSeries Results, Rwma Indicator) Calculate(TBarSeries source, int period = 14)
{
var indicator = new Rwma(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}