refactoring

This commit is contained in:
Miha Kralj
2025-12-16 21:16:50 -08:00
parent a67ad65fa5
commit d277e08056
137 changed files with 5074 additions and 3178 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ public class SmaIndicatorTests
{
var indicator = new SmaIndicator();
indicator.Initialize();
var method = indicator.GetType().GetMethod("OnPaintChart");
Assert.NotNull(method);
Assert.Equal(typeof(SmaIndicator), method.DeclaringType);
+5 -1
View File
@@ -59,8 +59,12 @@ public class SmaIndicator : Indicator, IWatchlistIndicator
public override void OnPaintChart(PaintChartEventArgs args)
{
var savedColor = Series!.Color;
Series.Color = Color.Transparent;
base.OnPaintChart(args);
Series.Color = savedColor;
int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count;
this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2);
this.PaintLine(args, Series!, warmupPeriod, showColdValues: ShowColdValues);
}
}
+97 -19
View File
@@ -331,7 +331,7 @@ public class SmaTests
}
[Fact]
public void Sma_StaticCalculate_Works()
public void Sma_StaticBatch_Works()
{
var series = new TSeries();
series.Add(DateTime.UtcNow.Ticks, 10);
@@ -340,7 +340,7 @@ public class SmaTests
series.Add(DateTime.UtcNow.Ticks + 3, 40);
series.Add(DateTime.UtcNow.Ticks + 4, 50);
var results = Sma.Calculate(series, 3);
var results = Sma.Batch(series, 3);
Assert.Equal(5, results.Count);
// SMA(3) for last value: (30+40+50)/3 = 40
@@ -360,22 +360,22 @@ public class SmaTests
// ============== Span API Tests ==============
[Fact]
public void Sma_SpanCalc_ValidatesInput()
public void Sma_SpanBatch_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
// Period must be > 0
Assert.Throws<ArgumentException>(() => Sma.Calculate(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Sma.Calculate(source.AsSpan(), output.AsSpan(), -1));
Assert.Throws<ArgumentException>(() => Sma.Batch(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Sma.Batch(source.AsSpan(), output.AsSpan(), -1));
// Output must be same length as source
Assert.Throws<ArgumentException>(() => Sma.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
Assert.Throws<ArgumentException>(() => Sma.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
}
[Fact]
public void Sma_SpanCalc_MatchesTSeriesCalc()
public void Sma_SpanBatch_MatchesTSeriesBatch()
{
var series = new TSeries();
double[] source = new double[100];
@@ -390,10 +390,10 @@ public class SmaTests
}
// Calculate with TSeries API
var tseriesResult = Sma.Calculate(series, 10);
var tseriesResult = Sma.Batch(series, 10);
// Calculate with Span API
Sma.Calculate(source.AsSpan(), output.AsSpan(), 10);
Sma.Batch(source.AsSpan(), output.AsSpan(), 10);
// Compare results
for (int i = 0; i < 100; i++)
@@ -403,12 +403,12 @@ public class SmaTests
}
[Fact]
public void Sma_SpanCalc_CalculatesCorrectly()
public void Sma_SpanBatch_CalculatesCorrectly()
{
double[] source = [10, 20, 30, 40, 50];
double[] output = new double[5];
Sma.Calculate(source.AsSpan(), output.AsSpan(), 3);
Sma.Batch(source.AsSpan(), output.AsSpan(), 3);
// SMA(3) warmup: 10, (10+20)/2=15, (10+20+30)/3=20, then sliding: (20+30+40)/3=30, (30+40+50)/3=40
Assert.Equal(10.0, output[0], 1e-10);
@@ -419,7 +419,7 @@ public class SmaTests
}
[Fact]
public void Sma_SpanCalc_ZeroAllocation()
public void Sma_SpanBatch_ZeroAllocation()
{
double[] source = new double[10000];
@@ -429,7 +429,7 @@ public class SmaTests
source[i] = gbm.Next().Close;
// Warm up
Sma.Calculate(source.AsSpan(), output.AsSpan(), 100);
Sma.Batch(source.AsSpan(), output.AsSpan(), 100);
// This test verifies the method runs without throwing
// (allocation is measured by BenchmarkDotNet, not unit tests)
@@ -437,12 +437,12 @@ public class SmaTests
}
[Fact]
public void Sma_SpanCalc_HandlesNaN()
public void Sma_SpanBatch_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Sma.Calculate(source.AsSpan(), output.AsSpan(), 3);
Sma.Batch(source.AsSpan(), output.AsSpan(), 3);
// All outputs should be finite
foreach (var val in output)
@@ -452,12 +452,12 @@ public class SmaTests
}
[Fact]
public void Sma_SpanCalc_Period1_ReturnsInput()
public void Sma_SpanBatch_Period1_ReturnsInput()
{
double[] source = [10, 20, 30, 40, 50];
double[] output = new double[5];
Sma.Calculate(source.AsSpan(), output.AsSpan(), 1);
Sma.Batch(source.AsSpan(), output.AsSpan(), 1);
for (int i = 0; i < source.Length; i++)
{
@@ -474,14 +474,14 @@ public class SmaTests
var series = bars.Close;
// 1. Batch Mode
var batchSeries = Sma.Calculate(series, period);
var batchSeries = Sma.Batch(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);
Sma.Batch(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
@@ -516,4 +516,82 @@ public class SmaTests
source.Add(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100, sma.Last.Value);
}
[Fact]
public void WarmupPeriod_IsSetCorrectly()
{
var sma = new Sma(10);
Assert.Equal(10, sma.WarmupPeriod);
}
[Fact]
public void Prime_SetsStateCorrectly()
{
var sma = new Sma(5);
double[] history = [10, 20, 30, 40, 50]; // SMA(5) = 30
sma.Prime(history);
Assert.True(sma.IsHot);
Assert.Equal(30.0, sma.Last.Value, 1e-10);
// Verify it continues correctly
sma.Update(new TValue(DateTime.UtcNow, 60)); // 20,30,40,50,60 -> 40
Assert.Equal(40.0, sma.Last.Value, 1e-10);
}
[Fact]
public void Prime_WithInsufficientHistory_IsNotHot()
{
var sma = new Sma(10);
double[] history = [10, 20, 30, 40, 50];
sma.Prime(history);
Assert.False(sma.IsHot);
Assert.Equal(30.0, sma.Last.Value, 1e-10); // It still calculates what it can
}
[Fact]
public void Prime_HandlesNaN_InHistory()
{
var sma = new Sma(3);
double[] history = [10, 20, double.NaN, 40];
// 10
// 10, 20
// 10, 20, 20 (NaN replaced by 20) -> Avg(10,20,20) = 16.666...
// 20, 20, 40 -> Avg(20,20,40) = 26.666...
sma.Prime(history);
Assert.True(sma.IsHot);
Assert.Equal(80.0 / 3.0, sma.Last.Value, 1e-9);
}
[Fact]
public void Calculate_ReturnsCorrectResultsAndHotIndicator()
{
var series = new TSeries();
for (int i = 1; i <= 10; i++) series.Add(DateTime.UtcNow, i * 10);
// 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
// SMA(5)
var (results, indicator) = Sma.Calculate(series, 5);
// Check results
Assert.Equal(10, results.Count);
Assert.Equal(30.0, results[4].Value); // 5th element (index 4) is SMA(10..50) = 30
Assert.Equal(80.0, results.Last.Value); // Last element is SMA(60..100) = 80
// Check indicator state
Assert.True(indicator.IsHot);
Assert.Equal(80.0, indicator.Last.Value);
Assert.Equal(5, indicator.WarmupPeriod);
// Verify indicator continues correctly
indicator.Update(new TValue(DateTime.UtcNow, 110));
// Window was [60, 70, 80, 90, 100] -> Avg 80
// New Window [70, 80, 90, 100, 110] -> Avg 90
Assert.Equal(90.0, indicator.Last.Value);
}
}
+3 -3
View File
@@ -91,7 +91,7 @@ public class SmaValidationTests : IDisposable
{
// Calculate QuanTAlib SMA (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.Sma.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
global::QuanTAlib.Sma.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
// Calculate Skender SMA
var sResult = _testData.SkenderQuotes.GetSma(period).ToList();
@@ -173,7 +173,7 @@ public class SmaValidationTests : IDisposable
{
// Calculate QuanTAlib SMA (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.Sma.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
global::QuanTAlib.Sma.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
// Calculate TA-Lib SMA
var retCode = TALib.Functions.Sma<double>(sourceData, 0..^0, talibOutput, out var outRange, period);
@@ -263,7 +263,7 @@ public class SmaValidationTests : IDisposable
{
// Calculate QuanTAlib SMA (Span API)
double[] qOutput = new double[sourceData.Length];
global::QuanTAlib.Sma.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period);
global::QuanTAlib.Sma.Batch(sourceData.AsSpan(), qOutput.AsSpan(), period);
// Calculate Tulip SMA
var smaIndicator = Tulip.Indicators.sma;
+111 -65
View File
@@ -26,7 +26,7 @@ namespace QuanTAlib;
/// Becomes true when the buffer is full (period samples processed).
/// </remarks>
[SkipLocalsInit]
public sealed class Sma : ITValuePublisher
public sealed class Sma : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
@@ -37,13 +37,6 @@ public sealed class Sma : ITValuePublisher
private const int ResyncInterval = 1000;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Creates SMA with specified period.
/// </summary>
@@ -56,6 +49,7 @@ public sealed class Sma : ITValuePublisher
_period = period;
_buffer = new RingBuffer(period);
Name = $"Sma({period})";
WarmupPeriod = period;
}
public Sma(ITValuePublisher source, int period) : this(period)
@@ -63,16 +57,93 @@ public sealed class Sma : ITValuePublisher
source.Pub += (item) => Update(item);
}
/// <summary>
/// Current SMA value.
/// </summary>
public TValue Last { get; private set; }
public Sma(TSeries source, int period) : this(period)
{
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
source.Pub += (item) => Update(item);
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode B: Streaming (Stateful)
/////////////////////////////////////////////////////////////////////////////////////////////////
/// <summary>
/// True if the SMA has enough data to produce valid results.
/// SMA is "hot" when the buffer is full (has received at least 'period' values).
/// </summary>
public bool IsHot => _buffer.IsFull;
public override bool IsHot => _buffer.IsFull;
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode C: Priming (The Bridge)
/////////////////////////////////////////////////////////////////////////////////////////////////
/// <summary>
/// Initializes the indicator state using the provided history.
/// Efficiently processes only the last 'Period' values required to sync the buffer.
/// </summary>
/// <param name="source">Historical data (only the last 'period' is actually needed)</param>
public override void Prime(ReadOnlySpan<double> source)
{
if (source.Length == 0) return;
// Reset state
_buffer.Clear();
_state = default;
_p_state = default;
// We only need the last 'period' values to fully restore state
// If history is shorter than period, we take it all.
int warmupLength = Math.Min(source.Length, WarmupPeriod);
int startIndex = source.Length - warmupLength;
// 1. Seed the LastValidValue (crucial for NaN handling)
// We must look backwards from start of our warmup window to find a valid predecessor
_state.LastValidValue = double.NaN;
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source[i]))
{
_state.LastValidValue = source[i];
break;
}
}
// If we didn't find a valid value in history, try finding one inside the warmup window
if (double.IsNaN(_state.LastValidValue))
{
for (int i = startIndex; i < source.Length; i++)
{
if (double.IsFinite(source[i]))
{
_state.LastValidValue = source[i];
break;
}
}
}
// 2. Feed the RingBuffer and State
for (int i = startIndex; i < source.Length; i++)
{
double val = GetValidValue(source[i]);
UpdateState(val);
_state.LastInput = val;
}
// 3. Finalize State
// Calculate the initial "Last" value so the indicator is ready to be read immediately
double result = _buffer.Count > 0 ? _state.Sum / _buffer.Count : double.NaN;
// Note: We can't infer accurate Time from a simple Span<double>,
// so we leave 'Last' with default time or user updates it on next Tick.
Last = new TValue(DateTime.MinValue, result);
// Backup state for the next update cycle
_p_state = _state;
}
/// <summary>
/// Gets a valid input value, using last-value substitution for non-finite inputs.
@@ -106,7 +177,7 @@ public sealed class Sma : ITValuePublisher
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
@@ -127,11 +198,11 @@ public sealed class Sma : ITValuePublisher
double result = _state.Sum / _buffer.Count;
Last = new TValue(input.Time, result);
Pub?.Invoke(Last);
PubEvent(Last);
return Last;
}
public TSeries Update(TSeries source)
public override TSeries Update(TSeries source)
{
if (source.Count == 0) return [];
@@ -144,65 +215,26 @@ public sealed class Sma : ITValuePublisher
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Calculate(source.Values, vSpan, _period);
Batch(source.Values, vSpan, _period);
source.Times.CopyTo(tSpan);
// Restore state
int windowSize = Math.Min(len, _period);
int startIndex = len - windowSize;
_state.LastValidValue = double.NaN;
bool found = false;
if (startIndex > 0)
{
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source.Values[i]))
{
_state.LastValidValue = source.Values[i];
found = true;
break;
}
}
}
if (!found)
{
for (int i = 0; i < len; i++)
{
if (double.IsFinite(source.Values[i]))
{
_state.LastValidValue = source.Values[i];
break;
}
}
}
_buffer.Clear();
_state.Sum = 0;
_state.TickCount = 0;
for (int i = startIndex; i < len; i++)
{
double val = GetValidValue(source.Values[i]);
UpdateState(val);
_state.LastInput = val;
}
_p_state = _state;
Prime(source.Values);
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode A: Batch (Stateless)
/////////////////////////////////////////////////////////////////////////////////////////////////
/// <summary>
/// Calculates SMA for the entire series using a new instance.
/// </summary>
/// <param name="source">Input series</param>
/// <param name="period">SMA period</param>
/// <returns>SMA series</returns>
public static TSeries Calculate(TSeries source, int period)
public static TSeries Batch(TSeries source, int period)
{
var sma = new Sma(period);
return sma.Update(source);
@@ -218,7 +250,7 @@ public sealed class Sma : ITValuePublisher
/// <param name="output">Output span (must be same length as source)</param>
/// <param name="period">SMA period (must be > 0)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
@@ -256,6 +288,20 @@ public sealed class Sma : ITValuePublisher
CalculateScalarCore(source, output, period);
}
/// <summary>
/// Runs a high-performance SIMD batch calculation on history and returns
/// a "Hot" Sma instance ready to process the next tick immediately.
/// </summary>
/// <param name="source">Historical time series</param>
/// <param name="period">SMA Period</param>
/// <returns>A tuple containing the full calculation results and the hot indicator instance</returns>
public static (TSeries Results, Sma Indicator) Calculate(TSeries source, int period)
{
var sma = new Sma(period);
TSeries results = sma.Update(source);
return (results, sma);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
{
@@ -268,7 +314,7 @@ public sealed class Sma : ITValuePublisher
double sum = 0;
double lastValid = double.NaN;
// Find first valid value to seed lastValid
for (int k = 0; k < len; k++)
{
@@ -541,7 +587,7 @@ public sealed class Sma : ITValuePublisher
/// <summary>
/// Resets the SMA state.
/// </summary>
public void Reset()
public override void Reset()
{
_buffer.Clear();
_state = default;
+3 -3
View File
@@ -68,12 +68,12 @@ Console.WriteLine($"IsHot: {sma.IsHot}"); // true when buffer is full
// Batch calculation (TSeries API)
TSeries source = ...;
TSeries results = Sma.Calculate(source, 10);
TSeries results = Sma.Batch(source, 10);
// High-performance Span API (zero allocation)
double[] prices = new double[10000];
double[] output = new double[10000];
Sma.Calculate(prices.AsSpan(), output.AsSpan(), period: 10);
Sma.Batch(prices.AsSpan(), output.AsSpan(), period: 10);
```
### Zero-Allocation Span API
@@ -86,7 +86,7 @@ double[] source = new double[200000];
double[] smaOutput = new double[200000];
// Zero heap allocation during calculation
Sma.Calculate(source.AsSpan(), smaOutput.AsSpan(), period: 100);
Sma.Batch(source.AsSpan(), smaOutput.AsSpan(), period: 100);
// Results are written directly to output buffer
Console.WriteLine($"Last SMA: {smaOutput[^1]}");