feat: Introduce ITValuePublisher interface and refactor indicators for event-driven value updates.

This commit is contained in:
Miha Kralj
2025-12-07 14:36:22 -08:00
parent 3b146b68bd
commit 3734a1c5f6
16 changed files with 572 additions and 177 deletions
+3 -1
View File
@@ -3,6 +3,8 @@
"ms-dotnettools.csdevkit", "ms-dotnettools.csdevkit",
"ms-dotnettools.csharp", "ms-dotnettools.csharp",
"ms-dotnettools.dotnet-interactive-vscode", "ms-dotnettools.dotnet-interactive-vscode",
"bierner.markdown-mermaid" "bierner.markdown-mermaid",
"visualstudioexptteam.vscodeintellicode",
"SonarSource.sonarlint-vscode"
] ]
} }
+63 -14
View File
@@ -13,13 +13,13 @@ public class DemaTests
var dema = new Dema(period); var dema = new Dema(period);
var ema1 = new Ema(period); var ema1 = new Ema(period);
var ema2 = new Ema(period); var ema2 = new Ema(period);
var r = new Random(123); // nosemgrep var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
// Act & Assert // Act & Assert
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
{ {
double val = r.NextDouble() * 100; var bar = gbm.Next(isNew: true);
var tVal = new TValue(DateTime.Now.AddMinutes(i), val); var tVal = new TValue(bar.Time, bar.Close);
var dVal = dema.Update(tVal); var dVal = dema.Update(tVal);
@@ -37,10 +37,12 @@ public class DemaTests
// Arrange // Arrange
int period = 10; int period = 10;
var source = new TSeries(); var source = new TSeries();
var r = new Random(123); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
{ {
source.Add(new TValue(DateTime.Now.AddMinutes(i), r.NextDouble() * 100)); var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
} }
// Act // Act
@@ -63,10 +65,11 @@ public class DemaTests
int count = 100; int count = 100;
var source = new double[count]; var source = new double[count];
var output = new double[count]; var output = new double[count];
var r = new Random(123); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
source[i] = r.NextDouble() * 100; source[i] = gbm.Next().Close;
} }
// Act // Act
@@ -89,13 +92,14 @@ public class DemaTests
double alpha = 2.0 / (period + 1); double alpha = 2.0 / (period + 1);
var demaPeriod = new Dema(period); var demaPeriod = new Dema(period);
var demaAlpha = new Dema(alpha); var demaAlpha = new Dema(alpha);
var r = new Random(123); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
// Act & Assert // Act & Assert
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
{ {
double val = r.NextDouble() * 100; var bar = gbm.Next(isNew: true);
var tVal = new TValue(DateTime.Now.AddMinutes(i), val); var tVal = new TValue(bar.Time, bar.Close);
var pVal = demaPeriod.Update(tVal); var pVal = demaPeriod.Update(tVal);
var aVal = demaAlpha.Update(tVal); var aVal = demaAlpha.Update(tVal);
@@ -110,10 +114,12 @@ public class DemaTests
// Arrange // Arrange
double alpha = 0.15; double alpha = 0.15;
var source = new TSeries(); var source = new TSeries();
var r = new Random(123); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
{ {
source.Add(new TValue(DateTime.Now.AddMinutes(i), r.NextDouble() * 100)); var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
} }
// Act // Act
@@ -136,10 +142,11 @@ public class DemaTests
int count = 100; int count = 100;
var source = new double[count]; var source = new double[count];
var output = new double[count]; var output = new double[count];
var r = new Random(123); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
source[i] = r.NextDouble() * 100; source[i] = gbm.Next().Close;
} }
// Act // Act
@@ -153,4 +160,46 @@ public class DemaTests
Assert.Equal(val.Value, output[i], 1e-9); Assert.Equal(val.Value, output[i], 1e-9);
} }
} }
[Fact]
public void Dema_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 = Dema.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];
Dema.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Dema(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 Dema(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+13 -6
View File
@@ -22,7 +22,7 @@ namespace QuanTAlib;
/// Becomes true when the second EMA converges (approx. 2x EMA convergence time). /// Becomes true when the second EMA converges (approx. 2x EMA convergence time).
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Dema public sealed class Dema : ITValuePublisher
{ {
private struct EmaState private struct EmaState
{ {
@@ -45,8 +45,9 @@ public sealed class Dema
private double _lastValidValue; private double _lastValidValue;
public string Name { get; } public string Name { get; }
public TValue Value { get; private set; } public TValue Last { get; private set; }
public bool IsHot => _state2.IsHot; public bool IsHot => _state2.IsHot;
public event Action<TValue>? Pub;
public Dema(int period) public Dema(int period)
{ {
@@ -57,6 +58,11 @@ public sealed class Dema
Name = $"Dema({period})"; Name = $"Dema({period})";
} }
public Dema(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
public Dema(double alpha) public Dema(double alpha)
{ {
if (alpha <= 0 || alpha > 1) throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha)); if (alpha <= 0 || alpha > 1) throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
@@ -93,8 +99,9 @@ public sealed class Dema
double e2 = Compute(e1, _alpha, _decay, ref _state2); double e2 = Compute(e1, _alpha, _decay, ref _state2);
double result = 2 * e1 - e2; double result = 2 * e1 - e2;
Value = new TValue(input.Time, result); Last = new TValue(input.Time, result);
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -141,7 +148,7 @@ public sealed class Dema
_p_state2 = s2; _p_state2 = s2;
_lastValidValue = lastValid; _lastValidValue = lastValid;
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -281,6 +288,6 @@ public sealed class Dema
_p_state1 = EmaState.New(); _p_state1 = EmaState.New();
_p_state2 = EmaState.New(); _p_state2 = EmaState.New();
_lastValidValue = 0; _lastValidValue = 0;
Value = default; Last = default;
} }
} }
+60 -16
View File
@@ -29,12 +29,12 @@ public class EmaTests
{ {
var ema = new Ema(10); var ema = new Ema(10);
Assert.Equal(0, ema.Value.Value); Assert.Equal(0, ema.Last.Value);
TValue result = ema.Update(new TValue(DateTime.UtcNow, 100)); TValue result = ema.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0); Assert.True(result.Value > 0);
Assert.Equal(result.Value, ema.Value.Value); Assert.Equal(result.Value, ema.Last.Value);
} }
[Fact] [Fact]
@@ -43,10 +43,10 @@ public class EmaTests
var ema = new Ema(10); var ema = new Ema(10);
ema.Update(new TValue(DateTime.UtcNow, 100), isNew: true); ema.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = ema.Value; double value1 = ema.Last.Value;
ema.Update(new TValue(DateTime.UtcNow, 105), isNew: true); ema.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
double value2 = ema.Value; double value2 = ema.Last.Value;
// Values should change with new bars // Values should change with new bars
Assert.NotEqual(value1, value2); Assert.NotEqual(value1, value2);
@@ -59,10 +59,10 @@ public class EmaTests
ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 100));
ema.Update(new TValue(DateTime.UtcNow, 110), isNew: true); ema.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = ema.Value; double beforeUpdate = ema.Last.Value;
ema.Update(new TValue(DateTime.UtcNow, 120), isNew: false); ema.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = ema.Value; double afterUpdate = ema.Last.Value;
// Update should change the value // Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate); Assert.NotEqual(beforeUpdate, afterUpdate);
@@ -75,16 +75,16 @@ public class EmaTests
ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 100));
ema.Update(new TValue(DateTime.UtcNow, 105)); ema.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = ema.Value; double valueBefore = ema.Last.Value;
ema.Reset(); ema.Reset();
Assert.Equal(0, ema.Value.Value); Assert.Equal(0, ema.Last.Value);
// After reset, should accept new values // After reset, should accept new values
ema.Update(new TValue(DateTime.UtcNow, 50)); ema.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, ema.Value.Value); Assert.NotEqual(0, ema.Last.Value);
Assert.NotEqual(valueBefore, ema.Value.Value); Assert.NotEqual(valueBefore, ema.Last.Value);
} }
[Fact] [Fact]
@@ -92,12 +92,12 @@ public class EmaTests
{ {
var ema = new Ema(10); var ema = new Ema(10);
Assert.Equal(0, ema.Value.Value); Assert.Equal(0, ema.Last.Value);
Assert.False(ema.IsHot); Assert.False(ema.IsHot);
ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, ema.Value.Value); Assert.NotEqual(0, ema.Last.Value);
} }
[Fact] [Fact]
@@ -195,7 +195,7 @@ public class EmaTests
} }
// Remember EMA state after 10 values // Remember EMA state after 10 values
double emaAfterTen = ema.Value; double emaAfterTen = ema.Last.Value;
// Generate 9 corrections with isNew=false (different values) // Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
@@ -254,7 +254,7 @@ public class EmaTests
ema.Update(new TValue(DateTime.UtcNow, 100)); ema.Update(new TValue(DateTime.UtcNow, 100));
// This should compile and work because TValue has implicit conversion to double // This should compile and work because TValue has implicit conversion to double
double result = ema.Value; double result = ema.Last.Value;
Assert.Equal(100.0, result, 1e-10); Assert.Equal(100.0, result, 1e-10);
} }
@@ -442,9 +442,10 @@ public class EmaTests
{ {
double[] source = new double[10000]; double[] source = new double[10000];
double[] output = new double[10000]; double[] output = new double[10000];
var rng = new Random(42); // nosemgrep
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++) for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100; source[i] = gbm.Next().Close;
// Warm up // Warm up
Ema.Calculate(source.AsSpan(), output.AsSpan(), 100); Ema.Calculate(source.AsSpan(), output.AsSpan(), 100);
@@ -499,4 +500,47 @@ public class EmaTests
Assert.True(double.IsFinite(output[^1])); Assert.True(double.IsFinite(output[^1]));
Assert.True(output[^1] > 10 && output[^1] <= 50); Assert.True(output[^1] > 10 && output[^1] <= 50);
} }
[Fact]
public void Ema_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 = Ema.Calculate(series, period);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray(); // Need array for Span modification safety if any
var spanInput = new ReadOnlySpan<double>(tValues);
var spanOutput = new double[tValues.Length];
Ema.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Ema(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 Ema(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
// Precision 9 due to potential accumulation differences in loop vs batch optimizations
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+20 -6
View File
@@ -25,7 +25,7 @@ namespace QuanTAlib;
/// Becomes true when n = ln(0.05) / ln(1 - alpha) /// Becomes true when n = ln(0.05) / ln(1 - alpha)
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Ema public sealed class Ema : ITValuePublisher
{ {
private struct State private struct State
{ {
@@ -48,6 +48,8 @@ public sealed class Ema
/// </summary> /// </summary>
public string Name { get; } public string Name { get; }
public event Action<TValue>? Pub;
/// <summary> /// <summary>
/// Creates EMA with specified period. /// Creates EMA with specified period.
/// Alpha = 2 / (period + 1) /// Alpha = 2 / (period + 1)
@@ -63,6 +65,17 @@ public sealed class Ema
Name = $"Ema({period})"; Name = $"Ema({period})";
} }
/// <summary>
/// Creates EMA with specified source and period.
/// Subscribes to source.Pub event.
/// </summary>
/// <param name="source">Source to subscribe to</param>
/// <param name="period">Period for EMA calculation</param>
public Ema(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
/// <summary> /// <summary>
/// Creates EMA with specified alpha smoothing factor. /// Creates EMA with specified alpha smoothing factor.
/// </summary> /// </summary>
@@ -80,7 +93,7 @@ public sealed class Ema
/// <summary> /// <summary>
/// Current EMA value. /// Current EMA value.
/// </summary> /// </summary>
public TValue Value { get; private set; } public TValue Last { get; private set; }
/// <summary> /// <summary>
/// True if the EMA has warmed up and is providing valid results. /// True if the EMA has warmed up and is providing valid results.
@@ -115,8 +128,9 @@ public sealed class Ema
double val = GetValidValue(input.Value); double val = GetValidValue(input.Value);
val = Compute(val, _alpha, _decay, ref _state); val = Compute(val, _alpha, _decay, ref _state);
Value = new TValue(input.Time, val); Last = new TValue(input.Time, val);
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -145,7 +159,7 @@ public sealed class Ema
sourceTimes.CopyTo(tSpan); sourceTimes.CopyTo(tSpan);
_p_state = _state; _p_state = _state;
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -277,6 +291,6 @@ public sealed class Ema
_state = State.New(); _state = State.New();
_p_state = _state; _p_state = _state;
_lastValidValue = 0; _lastValidValue = 0;
Value = default; Last = default;
} }
} }
+63 -20
View File
@@ -18,12 +18,12 @@ public class SmaTests
{ {
var sma = new Sma(10); var sma = new Sma(10);
Assert.Equal(0, sma.Value.Value); Assert.Equal(0, sma.Last.Value);
TValue result = sma.Update(new TValue(DateTime.UtcNow, 100)); TValue result = sma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0); Assert.True(result.Value > 0);
Assert.Equal(result.Value, sma.Value.Value); Assert.Equal(result.Value, sma.Last.Value);
} }
[Fact] [Fact]
@@ -42,10 +42,10 @@ public class SmaTests
var sma = new Sma(10); var sma = new Sma(10);
sma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); sma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = sma.Value; double value1 = sma.Last.Value;
sma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); sma.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
double value2 = sma.Value; double value2 = sma.Last.Value;
// Values should change with new bars // Values should change with new bars
Assert.NotEqual(value1, value2); Assert.NotEqual(value1, value2);
@@ -58,10 +58,10 @@ public class SmaTests
sma.Update(new TValue(DateTime.UtcNow, 100)); sma.Update(new TValue(DateTime.UtcNow, 100));
sma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); sma.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = sma.Value; double beforeUpdate = sma.Last.Value;
sma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); sma.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = sma.Value; double afterUpdate = sma.Last.Value;
// Update should change the value // Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate); Assert.NotEqual(beforeUpdate, afterUpdate);
@@ -74,16 +74,16 @@ public class SmaTests
sma.Update(new TValue(DateTime.UtcNow, 100)); sma.Update(new TValue(DateTime.UtcNow, 100));
sma.Update(new TValue(DateTime.UtcNow, 105)); sma.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = sma.Value; double valueBefore = sma.Last.Value;
sma.Reset(); sma.Reset();
Assert.Equal(0, sma.Value.Value); Assert.Equal(0, sma.Last.Value);
// After reset, should accept new values // After reset, should accept new values
sma.Update(new TValue(DateTime.UtcNow, 50)); sma.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, sma.Value.Value); Assert.NotEqual(0, sma.Last.Value);
Assert.NotEqual(valueBefore, sma.Value.Value); Assert.NotEqual(valueBefore, sma.Last.Value);
} }
[Fact] [Fact]
@@ -91,12 +91,12 @@ public class SmaTests
{ {
var sma = new Sma(10); var sma = new Sma(10);
Assert.Equal(0, sma.Value.Value); Assert.Equal(0, sma.Last.Value);
Assert.False(sma.IsHot); Assert.False(sma.IsHot);
sma.Update(new TValue(DateTime.UtcNow, 100)); sma.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, sma.Value.Value); Assert.NotEqual(0, sma.Last.Value);
} }
[Fact] [Fact]
@@ -128,7 +128,7 @@ public class SmaTests
sma.Update(new TValue(DateTime.UtcNow, 50)); sma.Update(new TValue(DateTime.UtcNow, 50));
// SMA(5) of 10,20,30,40,50 = 150/5 = 30 // SMA(5) of 10,20,30,40,50 = 150/5 = 30
Assert.Equal(30.0, sma.Value.Value, 1e-10); Assert.Equal(30.0, sma.Last.Value, 1e-10);
} }
[Fact] [Fact]
@@ -141,17 +141,17 @@ public class SmaTests
sma.Update(new TValue(DateTime.UtcNow, 30)); sma.Update(new TValue(DateTime.UtcNow, 30));
// SMA(3) of 10,20,30 = 60/3 = 20 // SMA(3) of 10,20,30 = 60/3 = 20
Assert.Equal(20.0, sma.Value.Value, 1e-10); Assert.Equal(20.0, sma.Last.Value, 1e-10);
sma.Update(new TValue(DateTime.UtcNow, 40)); sma.Update(new TValue(DateTime.UtcNow, 40));
// SMA(3) of 20,30,40 = 90/3 = 30 // SMA(3) of 20,30,40 = 90/3 = 30
Assert.Equal(30.0, sma.Value.Value, 1e-10); Assert.Equal(30.0, sma.Last.Value, 1e-10);
sma.Update(new TValue(DateTime.UtcNow, 50)); sma.Update(new TValue(DateTime.UtcNow, 50));
// SMA(3) of 30,40,50 = 120/3 = 40 // SMA(3) of 30,40,50 = 120/3 = 40
Assert.Equal(40.0, sma.Value.Value, 1e-10); Assert.Equal(40.0, sma.Last.Value, 1e-10);
} }
[Fact] [Fact]
@@ -170,7 +170,7 @@ public class SmaTests
} }
// Remember SMA state after 10 values // Remember SMA state after 10 values
double smaAfterTen = sma.Value; double smaAfterTen = sma.Last.Value;
// Generate 9 corrections with isNew=false (different values) // Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
@@ -229,7 +229,7 @@ public class SmaTests
sma.Update(new TValue(DateTime.UtcNow, 100)); sma.Update(new TValue(DateTime.UtcNow, 100));
// This should compile and work because TValue has implicit conversion to double // This should compile and work because TValue has implicit conversion to double
double result = sma.Value; double result = sma.Last.Value;
Assert.Equal(100.0, result, 1e-10); Assert.Equal(100.0, result, 1e-10);
} }
@@ -422,10 +422,11 @@ public class SmaTests
public void Sma_SpanCalc_ZeroAllocation() public void Sma_SpanCalc_ZeroAllocation()
{ {
double[] source = new double[10000]; double[] source = new double[10000];
double[] output = new double[10000]; double[] output = new double[10000];
var rng = new Random(42); // nosemgrep var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++) for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100; // nosemgrep source[i] = gbm.Next().Close;
// Warm up // Warm up
Sma.Calculate(source.AsSpan(), output.AsSpan(), 100); Sma.Calculate(source.AsSpan(), output.AsSpan(), 100);
@@ -463,4 +464,46 @@ public class SmaTests
Assert.Equal(source[i], output[i], 1e-10); Assert.Equal(source[i], output[i], 1e-10);
} }
} }
[Fact]
public void Sma_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 = Sma.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];
Sma.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Sma(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 Sma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+40 -44
View File
@@ -25,7 +25,7 @@ namespace QuanTAlib;
/// Becomes true when the buffer is full (period samples processed). /// Becomes true when the buffer is full (period samples processed).
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Sma public sealed class Sma : ITValuePublisher
{ {
private readonly int _period; private readonly int _period;
private readonly RingBuffer _buffer; private readonly RingBuffer _buffer;
@@ -44,6 +44,8 @@ public sealed class Sma
/// </summary> /// </summary>
public string Name { get; } public string Name { get; }
public event Action<TValue>? Pub;
/// <summary> /// <summary>
/// Creates SMA with specified period. /// Creates SMA with specified period.
/// </summary> /// </summary>
@@ -58,10 +60,15 @@ public sealed class Sma
Name = $"Sma({period})"; Name = $"Sma({period})";
} }
public Sma(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
/// <summary> /// <summary>
/// Current SMA value. /// Current SMA value.
/// </summary> /// </summary>
public TValue Value { get; private set; } public TValue Last { get; private set; }
/// <summary> /// <summary>
/// True if the SMA has enough data to produce valid results. /// True if the SMA has enough data to produce valid results.
@@ -100,6 +107,9 @@ public sealed class Sma
} }
} }
// Removed GetValidValue and UpdateState as they are not used in the new Update logic
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true) public TValue Update(TValue input, bool isNew = true)
{ {
@@ -107,7 +117,16 @@ public sealed class Sma
{ {
double val = GetValidValue(input.Value); double val = GetValidValue(input.Value);
UpdateState(val); double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_sum = _sum - removedValue + val;
_buffer.Add(val);
_tickCount++;
if (_buffer.IsFull && _tickCount >= ResyncInterval)
{
_tickCount = 0;
_sum = _buffer.Sum();
}
_p_sum = _sum; _p_sum = _sum;
_p_lastInput = val; _p_lastInput = val;
@@ -116,17 +135,16 @@ public sealed class Sma
else else
{ {
_lastValidValue = _p_lastValidValue; _lastValidValue = _p_lastValidValue;
double val = GetValidValue(input.Value); double val = GetValidValue(input.Value);
_sum = _p_sum - _p_lastInput + val; _sum = _p_sum - _p_lastInput + val;
_buffer.UpdateNewest(val); _buffer.UpdateNewest(val);
} }
double result = _sum / _buffer.Count; double result = _sum / _buffer.Count;
Value = new TValue(input.Time, result); Last = new TValue(input.Time, result);
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -144,44 +162,25 @@ public sealed class Sma
var sourceValues = source.Values; var sourceValues = source.Values;
var sourceTimes = source.Times; var sourceTimes = source.Times;
Calculate(sourceValues, vSpan, _period); // Reset state for batch calculation
Reset();
sourceTimes.CopyTo(tSpan); // We can optimize this later with specific batch logic, but for now use core loop
for(int i=0; i < len; i++)
int windowSize = Math.Min(len, _period);
int startIndex = len - windowSize;
if (startIndex > 0)
{
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(sourceValues[i]))
{
_lastValidValue = sourceValues[i];
break;
}
}
}
else
{
_lastValidValue = 0;
}
_buffer.Clear();
_sum = 0;
_tickCount = 0;
for (int i = startIndex; i < len; i++)
{ {
double val = GetValidValue(sourceValues[i]); double val = GetValidValue(sourceValues[i]);
UpdateState(val); double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_sum = _sum - removedValue + val;
_buffer.Add(val);
vSpan[i] = _sum / _buffer.Count;
} }
_p_sum = _sum; sourceTimes.CopyTo(tSpan);
_p_lastInput = sourceValues[len - 1];
_p_lastValidValue = _lastValidValue; _p_lastValidValue = _lastValidValue;
_p_sum = _sum;
_p_lastInput = sourceValues[len-1];
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -382,12 +381,9 @@ public sealed class Sma
public void Reset() public void Reset()
{ {
_buffer.Clear(); _buffer.Clear();
_sum = 0; var resetSum = 0;
_p_sum = 0; _sum = resetSum;
_p_lastInput = 0; Last = default;
_lastValidValue = 0;
_p_lastValidValue = 0;
_tickCount = 0; _tickCount = 0;
Value = default;
} }
} }
+58 -15
View File
@@ -29,12 +29,12 @@ public class TemaTests
{ {
var tema = new Tema(10); var tema = new Tema(10);
Assert.Equal(0, tema.Value.Value); Assert.Equal(0, tema.Last.Value);
TValue result = tema.Update(new TValue(DateTime.UtcNow, 100)); TValue result = tema.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0); Assert.True(result.Value > 0);
Assert.Equal(result.Value, tema.Value.Value); Assert.Equal(result.Value, tema.Last.Value);
} }
[Fact] [Fact]
@@ -43,10 +43,10 @@ public class TemaTests
var tema = new Tema(10); var tema = new Tema(10);
tema.Update(new TValue(DateTime.UtcNow, 100), isNew: true); tema.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = tema.Value; double value1 = tema.Last.Value;
tema.Update(new TValue(DateTime.UtcNow, 105), isNew: true); tema.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
double value2 = tema.Value; double value2 = tema.Last.Value;
// Values should change with new bars // Values should change with new bars
Assert.NotEqual(value1, value2); Assert.NotEqual(value1, value2);
@@ -59,10 +59,10 @@ public class TemaTests
tema.Update(new TValue(DateTime.UtcNow, 100)); tema.Update(new TValue(DateTime.UtcNow, 100));
tema.Update(new TValue(DateTime.UtcNow, 110), isNew: true); tema.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = tema.Value; double beforeUpdate = tema.Last.Value;
tema.Update(new TValue(DateTime.UtcNow, 120), isNew: false); tema.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = tema.Value; double afterUpdate = tema.Last.Value;
// Update should change the value // Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate); Assert.NotEqual(beforeUpdate, afterUpdate);
@@ -75,16 +75,16 @@ public class TemaTests
tema.Update(new TValue(DateTime.UtcNow, 100)); tema.Update(new TValue(DateTime.UtcNow, 100));
tema.Update(new TValue(DateTime.UtcNow, 105)); tema.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = tema.Value; double valueBefore = tema.Last.Value;
tema.Reset(); tema.Reset();
Assert.Equal(0, tema.Value.Value); Assert.Equal(0, tema.Last.Value);
// After reset, should accept new values // After reset, should accept new values
tema.Update(new TValue(DateTime.UtcNow, 50)); tema.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, tema.Value.Value); Assert.NotEqual(0, tema.Last.Value);
Assert.NotEqual(valueBefore, tema.Value.Value); Assert.NotEqual(valueBefore, tema.Last.Value);
} }
[Fact] [Fact]
@@ -92,12 +92,12 @@ public class TemaTests
{ {
var tema = new Tema(10); var tema = new Tema(10);
Assert.Equal(0, tema.Value.Value); Assert.Equal(0, tema.Last.Value);
Assert.False(tema.IsHot); Assert.False(tema.IsHot);
tema.Update(new TValue(DateTime.UtcNow, 100)); tema.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, tema.Value.Value); Assert.NotEqual(0, tema.Last.Value);
} }
[Fact] [Fact]
@@ -152,7 +152,7 @@ public class TemaTests
} }
// Remember TEMA state after 10 values // Remember TEMA state after 10 values
double temaAfterTen = tema.Value; double temaAfterTen = tema.Last.Value;
// Generate 9 corrections with isNew=false (different values) // Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
@@ -253,10 +253,11 @@ public class TemaTests
public void Tema_SpanCalc_ZeroAllocation() public void Tema_SpanCalc_ZeroAllocation()
{ {
double[] source = new double[10000]; double[] source = new double[10000];
double[] output = new double[10000]; double[] output = new double[10000];
var rng = new Random(42); // nosemgrep var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++) for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100; source[i] = gbm.Next().Close;
// Warm up // Warm up
Tema.Calculate(source.AsSpan(), output.AsSpan(), 100); Tema.Calculate(source.AsSpan(), output.AsSpan(), 100);
@@ -264,4 +265,46 @@ public class TemaTests
// This test verifies the method runs without throwing // This test verifies the method runs without throwing
Assert.True(double.IsFinite(output[^1])); Assert.True(double.IsFinite(output[^1]));
} }
[Fact]
public void Tema_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 = Tema.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];
Tema.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Tema(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 Tema(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+13 -6
View File
@@ -24,7 +24,7 @@ namespace QuanTAlib;
/// which is faster than the standard EMA convergence (3/alpha steps). /// which is faster than the standard EMA convergence (3/alpha steps).
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Tema public sealed class Tema : ITValuePublisher
{ {
private struct EmaState private struct EmaState
{ {
@@ -49,8 +49,9 @@ public sealed class Tema
private double _lastValidValue; private double _lastValidValue;
public string Name { get; } public string Name { get; }
public TValue Value { get; private set; } public TValue Last { get; private set; }
public bool IsHot => _state3.E <= 0.09; public bool IsHot => _state3.E <= 0.09;
public event Action<TValue>? Pub;
public Tema(int period) public Tema(int period)
{ {
@@ -61,6 +62,11 @@ public sealed class Tema
Name = $"Tema({period})"; Name = $"Tema({period})";
} }
public Tema(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
public Tema(double alpha) public Tema(double alpha)
{ {
if (alpha <= 0 || alpha > 1) throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha)); if (alpha <= 0 || alpha > 1) throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
@@ -102,8 +108,9 @@ public sealed class Tema
double e3 = Compute(e2, _alpha, _decay, ref _state3); double e3 = Compute(e2, _alpha, _decay, ref _state3);
double result = 3 * e1 - 3 * e2 + e3; double result = 3 * e1 - 3 * e2 + e3;
Value = new TValue(input.Time, result); Last = new TValue(input.Time, result);
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -154,7 +161,7 @@ public sealed class Tema
_p_state3 = s3; _p_state3 = s3;
_lastValidValue = lastValid; _lastValidValue = lastValid;
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -322,6 +329,6 @@ public sealed class Tema
_p_state2 = EmaState.New(); _p_state2 = EmaState.New();
_p_state3 = EmaState.New(); _p_state3 = EmaState.New();
_lastValidValue = 0; _lastValidValue = 0;
Value = default; Last = default;
} }
} }
+46 -4
View File
@@ -19,12 +19,12 @@ public class TrimaTests
{ {
var trima = new Trima(10); var trima = new Trima(10);
Assert.Equal(0, trima.Value.Value); Assert.Equal(0, trima.Last.Value);
TValue result = trima.Update(new TValue(DateTime.UtcNow, 100)); TValue result = trima.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0); Assert.True(result.Value > 0);
Assert.Equal(result.Value, trima.Value.Value); Assert.Equal(result.Value, trima.Last.Value);
} }
[Fact] [Fact]
@@ -111,12 +111,12 @@ public class TrimaTests
trima.Reset(); trima.Reset();
Assert.Equal(0, trima.Value.Value); Assert.Equal(0, trima.Last.Value);
Assert.False(trima.IsHot); Assert.False(trima.IsHot);
// After reset, should accept new values // After reset, should accept new values
trima.Update(new TValue(DateTime.UtcNow, 50)); trima.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, trima.Value.Value); Assert.NotEqual(0, trima.Last.Value);
} }
[Fact] [Fact]
@@ -197,4 +197,46 @@ public class TrimaTests
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10); Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
} }
} }
[Fact]
public void Trima_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 = Trima.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];
Trima.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Trima(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 Trima(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+14 -7
View File
@@ -23,7 +23,7 @@ namespace QuanTAlib;
/// Becomes true when the buffer is full (period samples processed). /// Becomes true when the buffer is full (period samples processed).
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Trima public sealed class Trima : ITValuePublisher
{ {
private readonly int _period; private readonly int _period;
private readonly int _p1; private readonly int _p1;
@@ -41,8 +41,9 @@ public sealed class Trima
private const int ResyncInterval = 1000; private const int ResyncInterval = 1000;
public string Name { get; } public string Name { get; }
public TValue Value { get; private set; } public TValue Last { get; private set; }
public bool IsHot => _sampleCount >= _period; public bool IsHot => _sampleCount >= _period;
public event Action<TValue>? Pub;
public Trima(int period) public Trima(int period)
{ {
@@ -58,6 +59,11 @@ public sealed class Trima
Name = $"Trima({period})"; Name = $"Trima({period})";
} }
public Trima(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input) private double GetValidValue(double input)
{ {
@@ -110,7 +116,7 @@ public sealed class Trima
_p_sum2 = _sum2; _p_sum2 = _sum2;
_p_lastInput2 = sma1Result; _p_lastInput2 = sma1Result;
Value = new TValue(input.Time, _sum2 / _buffer2.Count); Last = new TValue(input.Time, _sum2 / _buffer2.Count);
} }
else else
{ {
@@ -126,10 +132,11 @@ public sealed class Trima
_sum2 = _p_sum2 - _p_lastInput2 + sma1Result; _sum2 = _p_sum2 - _p_lastInput2 + sma1Result;
_buffer2.UpdateNewest(sma1Result); _buffer2.UpdateNewest(sma1Result);
Value = new TValue(input.Time, _sum2 / _buffer2.Count); Last = new TValue(input.Time, _sum2 / _buffer2.Count);
} }
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -158,7 +165,7 @@ public sealed class Trima
Update(new TValue(source.Times[i], source.Values[i]), isNew: true); Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
} }
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -204,6 +211,6 @@ public sealed class Trima
_tickCount2 = 0; _tickCount2 = 0;
_sampleCount = 0; _sampleCount = 0;
Value = default; Last = default;
} }
} }
+67 -25
View File
@@ -18,12 +18,12 @@ public class WmaTests
{ {
var wma = new Wma(10); var wma = new Wma(10);
Assert.Equal(0, wma.Value.Value); Assert.Equal(0, wma.Last.Value);
TValue result = wma.Update(new TValue(DateTime.UtcNow, 100)); TValue result = wma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0); Assert.True(result.Value > 0);
Assert.Equal(result.Value, wma.Value.Value); Assert.Equal(result.Value, wma.Last.Value);
} }
[Fact] [Fact]
@@ -42,10 +42,10 @@ public class WmaTests
var wma = new Wma(10); var wma = new Wma(10);
wma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); wma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
double value1 = wma.Value; double value1 = wma.Last.Value;
wma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); wma.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
double value2 = wma.Value; double value2 = wma.Last.Value;
// Values should change with new bars // Values should change with new bars
Assert.NotEqual(value1, value2); Assert.NotEqual(value1, value2);
@@ -58,10 +58,10 @@ public class WmaTests
wma.Update(new TValue(DateTime.UtcNow, 100)); wma.Update(new TValue(DateTime.UtcNow, 100));
wma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); wma.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
double beforeUpdate = wma.Value; double beforeUpdate = wma.Last.Value;
wma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); wma.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
double afterUpdate = wma.Value; double afterUpdate = wma.Last.Value;
// Update should change the value // Update should change the value
Assert.NotEqual(beforeUpdate, afterUpdate); Assert.NotEqual(beforeUpdate, afterUpdate);
@@ -74,16 +74,16 @@ public class WmaTests
wma.Update(new TValue(DateTime.UtcNow, 100)); wma.Update(new TValue(DateTime.UtcNow, 100));
wma.Update(new TValue(DateTime.UtcNow, 105)); wma.Update(new TValue(DateTime.UtcNow, 105));
double valueBefore = wma.Value; double valueBefore = wma.Last.Value;
wma.Reset(); wma.Reset();
Assert.Equal(0, wma.Value.Value); Assert.Equal(0, wma.Last.Value);
// After reset, should accept new values // After reset, should accept new values
wma.Update(new TValue(DateTime.UtcNow, 50)); wma.Update(new TValue(DateTime.UtcNow, 50));
Assert.NotEqual(0, wma.Value.Value); Assert.NotEqual(0, wma.Last.Value);
Assert.NotEqual(valueBefore, wma.Value.Value); Assert.NotEqual(valueBefore, wma.Last.Value);
} }
[Fact] [Fact]
@@ -91,12 +91,12 @@ public class WmaTests
{ {
var wma = new Wma(10); var wma = new Wma(10);
Assert.Equal(0, wma.Value.Value); Assert.Equal(0, wma.Last.Value);
Assert.False(wma.IsHot); Assert.False(wma.IsHot);
wma.Update(new TValue(DateTime.UtcNow, 100)); wma.Update(new TValue(DateTime.UtcNow, 100));
Assert.NotEqual(0, wma.Value.Value); Assert.NotEqual(0, wma.Last.Value);
} }
[Fact] [Fact]
@@ -129,7 +129,7 @@ public class WmaTests
// WMA(5) of 10,20,30,40,50 = (1*10 + 2*20 + 3*30 + 4*40 + 5*50) / 15 // WMA(5) of 10,20,30,40,50 = (1*10 + 2*20 + 3*30 + 4*40 + 5*50) / 15
// = (10 + 40 + 90 + 160 + 250) / 15 = 550 / 15 = 36.666... // = (10 + 40 + 90 + 160 + 250) / 15 = 550 / 15 = 36.666...
Assert.Equal(550.0 / 15.0, wma.Value.Value, 1e-10); Assert.Equal(550.0 / 15.0, wma.Last.Value, 1e-10);
} }
[Fact] [Fact]
@@ -142,17 +142,17 @@ public class WmaTests
wma.Update(new TValue(DateTime.UtcNow, 30)); wma.Update(new TValue(DateTime.UtcNow, 30));
// WMA(3) of 10,20,30 = (1*10 + 2*20 + 3*30) / 6 = (10 + 40 + 90) / 6 = 140/6 = 23.333... // WMA(3) of 10,20,30 = (1*10 + 2*20 + 3*30) / 6 = (10 + 40 + 90) / 6 = 140/6 = 23.333...
Assert.Equal(140.0 / 6.0, wma.Value.Value, 1e-10); Assert.Equal(140.0 / 6.0, wma.Last.Value, 1e-10);
wma.Update(new TValue(DateTime.UtcNow, 40)); wma.Update(new TValue(DateTime.UtcNow, 40));
// WMA(3) of 20,30,40 = (1*20 + 2*30 + 3*40) / 6 = (20 + 60 + 120) / 6 = 200/6 = 33.333... // WMA(3) of 20,30,40 = (1*20 + 2*30 + 3*40) / 6 = (20 + 60 + 120) / 6 = 200/6 = 33.333...
Assert.Equal(200.0 / 6.0, wma.Value.Value, 1e-10); Assert.Equal(200.0 / 6.0, wma.Last.Value, 1e-10);
wma.Update(new TValue(DateTime.UtcNow, 50)); wma.Update(new TValue(DateTime.UtcNow, 50));
// WMA(3) of 30,40,50 = (1*30 + 2*40 + 3*50) / 6 = (30 + 80 + 150) / 6 = 260/6 = 43.333... // WMA(3) of 30,40,50 = (1*30 + 2*40 + 3*50) / 6 = (30 + 80 + 150) / 6 = 260/6 = 43.333...
Assert.Equal(260.0 / 6.0, wma.Value.Value, 1e-10); Assert.Equal(260.0 / 6.0, wma.Last.Value, 1e-10);
} }
[Fact] [Fact]
@@ -171,7 +171,7 @@ public class WmaTests
} }
// Remember WMA state after 10 values // Remember WMA state after 10 values
double wmaAfterTen = wma.Value; double wmaAfterTen = wma.Last.Value;
// Generate 9 corrections with isNew=false (different values) // Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
@@ -230,7 +230,7 @@ public class WmaTests
wma.Update(new TValue(DateTime.UtcNow, 100)); wma.Update(new TValue(DateTime.UtcNow, 100));
// This should compile and work because TValue has implicit conversion to double // This should compile and work because TValue has implicit conversion to double
double result = wma.Value; double result = wma.Last.Value;
Assert.Equal(100.0, result, 1e-10); Assert.Equal(100.0, result, 1e-10);
} }
@@ -375,9 +375,9 @@ public class WmaTests
// WMA should be higher than SMA because it weights the high recent value more // WMA should be higher than SMA because it weights the high recent value more
// SMA = (10 + 20 + 100) / 3 = 43.333... // SMA = (10 + 20 + 100) / 3 = 43.333...
// WMA = (1*10 + 2*20 + 3*100) / 6 = (10 + 40 + 300) / 6 = 58.333... // WMA = (1*10 + 2*20 + 3*100) / 6 = (10 + 40 + 300) / 6 = 58.333...
Assert.True(wma.Value.Value > sma.Value.Value); Assert.True(wma.Last.Value > sma.Last.Value);
Assert.Equal(350.0 / 6.0, wma.Value.Value, 1e-10); Assert.Equal(350.0 / 6.0, wma.Last.Value, 1e-10);
Assert.Equal(130.0 / 3.0, sma.Value.Value, 1e-10); Assert.Equal(130.0 / 3.0, sma.Last.Value, 1e-10);
} }
[Fact] [Fact]
@@ -469,9 +469,9 @@ public class WmaTests
{ {
double[] source = new double[10000]; double[] source = new double[10000];
double[] output = new double[10000]; double[] output = new double[10000];
var rng = new Random(42); // nosemgrep var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++) for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100; source[i] = gbm.Next().Close;
// Warm up // Warm up
Wma.Calculate(source.AsSpan(), output.AsSpan(), 100); Wma.Calculate(source.AsSpan(), output.AsSpan(), 100);
@@ -514,9 +514,9 @@ public class WmaTests
{ {
double[] source = new double[1000]; double[] source = new double[1000];
double[] output = new double[1000]; double[] output = new double[1000];
var rng = new Random(42); // nosemgrep var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < source.Length; i++) for (int i = 0; i < source.Length; i++)
source[i] = rng.NextDouble() * 100; source[i] = gbm.Next().Close;
// Period <= 512 uses stackalloc // Period <= 512 uses stackalloc
Wma.Calculate(source.AsSpan(), output.AsSpan(), 100); Wma.Calculate(source.AsSpan(), output.AsSpan(), 100);
@@ -527,4 +527,46 @@ public class WmaTests
Wma.Calculate(source.AsSpan(), output2.AsSpan(), 600); Wma.Calculate(source.AsSpan(), output2.AsSpan(), 600);
Assert.True(double.IsFinite(output2[^1])); Assert.True(double.IsFinite(output2[^1]));
} }
[Fact]
public void Wma_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 = Wma.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];
Wma.Calculate(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Wma(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 Wma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
} }
+13 -6
View File
@@ -25,7 +25,7 @@ namespace QuanTAlib;
/// Becomes true when the buffer is full (period samples processed). /// Becomes true when the buffer is full (period samples processed).
/// </remarks> /// </remarks>
[SkipLocalsInit] [SkipLocalsInit]
public sealed class Wma public sealed class Wma : ITValuePublisher
{ {
private readonly int _period; private readonly int _period;
private readonly double _divisor; private readonly double _divisor;
@@ -38,8 +38,9 @@ public sealed class Wma
private const int ResyncInterval = 1000; private const int ResyncInterval = 1000;
public string Name { get; } public string Name { get; }
public TValue Value { get; private set; } public TValue Last { get; private set; }
public bool IsHot => _buffer.IsFull; public bool IsHot => _buffer.IsFull;
public event Action<TValue>? Pub;
public Wma(int period) public Wma(int period)
{ {
@@ -51,6 +52,11 @@ public sealed class Wma
Name = $"Wma({period})"; Name = $"Wma({period})";
} }
public Wma(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input) private double GetValidValue(double input)
{ {
@@ -128,8 +134,9 @@ public sealed class Wma
} }
double currentDivisor = _buffer.IsFull ? _divisor : _buffer.Count * (_buffer.Count + 1) * 0.5; double currentDivisor = _buffer.IsFull ? _divisor : _buffer.Count * (_buffer.Count + 1) * 0.5;
Value = new TValue(input.Time, _wsum / currentDivisor); Last = new TValue(input.Time, _wsum / currentDivisor);
return Value; Pub?.Invoke(Last);
return Last;
} }
public TSeries Update(TSeries source) public TSeries Update(TSeries source)
@@ -184,7 +191,7 @@ public sealed class Wma
_p_lastInput = source.Values[len - 1]; _p_lastInput = source.Values[len - 1];
_p_lastValidValue = _lastValidValue; _p_lastValidValue = _lastValidValue;
Value = new TValue(tSpan[len - 1], vSpan[len - 1]); Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v); return new TSeries(t, v);
} }
@@ -485,6 +492,6 @@ public sealed class Wma
{ {
_buffer.Clear(); _buffer.Clear();
_sum = _wsum = _p_sum = _p_wsum = _p_lastInput = _lastValidValue = _p_lastValidValue = 0; _sum = _wsum = _p_sum = _p_wsum = _p_lastInput = _lastValidValue = _p_lastValidValue = 0;
Value = default; Last = default;
} }
} }
+14
View File
@@ -0,0 +1,14 @@
using System;
namespace QuanTAlib;
/// <summary>
/// Interface for objects that publish TValue updates.
/// </summary>
public interface ITValuePublisher
{
/// <summary>
/// Event triggered when a new TValue is available.
/// </summary>
event Action<TValue> Pub;
}
+2 -2
View File
@@ -11,8 +11,8 @@ namespace QuanTAlib;
/// Stores Time (long) and Value (double) in separate contiguous arrays for SIMD efficiency. /// Stores Time (long) and Value (double) in separate contiguous arrays for SIMD efficiency.
/// Supports "New Bar" vs "Update Last" streaming semantics. /// Supports "New Bar" vs "Update Last" streaming semantics.
/// </summary> /// </summary>
public class TSeries : IReadOnlyList<TValue> public class TSeries : IReadOnlyList<TValue>, ITValuePublisher
{ {
protected readonly List<long> _t; protected readonly List<long> _t;
protected readonly List<double> _v; protected readonly List<double> _v;
+78
View File
@@ -153,6 +153,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("SMA")]
[Benchmark(Description = "QuanTAlib SMA (Eventing)")]
public void QuanTAlib_Sma_Eventing()
{
var source = new TSeries();
var sma = new Sma(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = sma.Last.Value;
}
}
[BenchmarkCategory("SMA")] [BenchmarkCategory("SMA")]
[Benchmark(Description = "Tulip SMA")] [Benchmark(Description = "Tulip SMA")]
public void Tulip_Sma() => Tulip.Indicators.sma.Run(_tulipSmaInputs, _tulipSmaOptions, _tulipSmaOutputs); public void Tulip_Sma() => Tulip.Indicators.sma.Run(_tulipSmaInputs, _tulipSmaOptions, _tulipSmaOutputs);
@@ -193,6 +206,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("EMA")]
[Benchmark(Description = "QuanTAlib EMA (Eventing)")]
public void QuanTAlib_Ema_Eventing()
{
var source = new TSeries();
var ema = new Ema(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = ema.Last.Value;
}
}
[BenchmarkCategory("EMA")] [BenchmarkCategory("EMA")]
[Benchmark(Description = "Tulip EMA")] [Benchmark(Description = "Tulip EMA")]
public void Tulip_Ema() => Tulip.Indicators.ema.Run(_tulipEmaInputs, _tulipEmaOptions, _tulipEmaOutputs); public void Tulip_Ema() => Tulip.Indicators.ema.Run(_tulipEmaInputs, _tulipEmaOptions, _tulipEmaOutputs);
@@ -233,6 +259,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("WMA")]
[Benchmark(Description = "QuanTAlib WMA (Eventing)")]
public void QuanTAlib_Wma_Eventing()
{
var source = new TSeries();
var wma = new Wma(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = wma.Last.Value;
}
}
[BenchmarkCategory("WMA")] [BenchmarkCategory("WMA")]
[Benchmark(Description = "Tulip WMA")] [Benchmark(Description = "Tulip WMA")]
public void Tulip_Wma() => Tulip.Indicators.wma.Run(_tulipWmaInputs, _tulipWmaOptions, _tulipWmaOutputs); public void Tulip_Wma() => Tulip.Indicators.wma.Run(_tulipWmaInputs, _tulipWmaOptions, _tulipWmaOutputs);
@@ -273,6 +312,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("TRIMA")]
[Benchmark(Description = "QuanTAlib TRIMA (Eventing)")]
public void QuanTAlib_Trima_Eventing()
{
var source = new TSeries();
var trima = new Trima(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = trima.Last.Value;
}
}
[BenchmarkCategory("TRIMA")] [BenchmarkCategory("TRIMA")]
[Benchmark(Description = "Tulip TRIMA")] [Benchmark(Description = "Tulip TRIMA")]
public void Tulip_Trima() => Tulip.Indicators.trima.Run(_tulipTrimaInputs, _tulipTrimaOptions, _tulipTrimaOutputs); public void Tulip_Trima() => Tulip.Indicators.trima.Run(_tulipTrimaInputs, _tulipTrimaOptions, _tulipTrimaOutputs);
@@ -301,6 +353,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("DEMA")]
[Benchmark(Description = "QuanTAlib DEMA (Eventing)")]
public void QuanTAlib_Dema_Eventing()
{
var source = new TSeries();
var dema = new Dema(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = dema.Last.Value;
}
}
[BenchmarkCategory("DEMA")] [BenchmarkCategory("DEMA")]
[Benchmark(Description = "Tulip DEMA")] [Benchmark(Description = "Tulip DEMA")]
public void Tulip_Dema() => Tulip.Indicators.dema.Run(_tulipDemaInputs, _tulipDemaOptions, _tulipDemaOutputs); public void Tulip_Dema() => Tulip.Indicators.dema.Run(_tulipDemaInputs, _tulipDemaOptions, _tulipDemaOutputs);
@@ -341,6 +406,19 @@ public class IndicatorBenchmarks
} }
} }
[BenchmarkCategory("TEMA")]
[Benchmark(Description = "QuanTAlib TEMA (Eventing)")]
public void QuanTAlib_Tema_Eventing()
{
var source = new TSeries();
var tema = new Tema(source, Period);
for (int i = 0; i < _closeValues.Length; i++)
{
source.Add(new TValue(_closeTseries.Times[i], _closeValues[i]));
_quantalibOutput[i] = tema.Last.Value;
}
}
[BenchmarkCategory("TEMA")] [BenchmarkCategory("TEMA")]
[Benchmark(Description = "Tulip TEMA")] [Benchmark(Description = "Tulip TEMA")]
public void Tulip_Tema() => Tulip.Indicators.tema.Run(_tulipTemaInputs, _tulipTemaOptions, _tulipTemaOutputs); public void Tulip_Tema() => Tulip.Indicators.tema.Run(_tulipTemaInputs, _tulipTemaOptions, _tulipTemaOutputs);