Merge dev into main: v0.8.7 Kahan compensated summation

This commit is contained in:
Miha Kralj
2026-03-13 22:01:52 -07:00
79 changed files with 2923 additions and 2495 deletions
+3 -22
View File
@@ -20,9 +20,9 @@ namespace QuanTAlib;
public sealed class Ema : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(double Ema, double E, bool IsHot, bool IsCompensated, int TickCount)
private record struct State(double Ema, double E, bool IsHot, bool IsCompensated)
{
public static State New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false, TickCount = 0 };
public static State New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false };
}
private readonly double _alpha;
@@ -32,12 +32,6 @@ public sealed class Ema : AbstractBase
private double _lastValidValue;
private double _p_lastValidValue;
/// <summary>
/// Interval for periodic resync to prevent floating-point drift accumulation.
/// After this many updates, the EMA state is recalculated from a checkpoint.
/// </summary>
private const int ResyncInterval = 10000;
/// <summary>
/// Creates EMA with specified period.
/// Alpha = 2 / (period + 1)
@@ -286,7 +280,7 @@ public sealed class Ema : AbstractBase
/// <summary>
/// Core EMA calculation with bias compensation and NaN handling.
/// Uses FMA for precision and includes periodic resync for long streams.
/// Uses FMA for precision. IIR filters are inherently self-correcting.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output, double alpha, ref State state, ref double lastValidValue)
@@ -319,7 +313,6 @@ public sealed class Ema : AbstractBase
}
output[i] = state.Ema / (1.0 - state.E);
state.TickCount++;
}
if (state.E <= COMPENSATOR_THRESHOLD)
{
@@ -389,17 +382,6 @@ public sealed class Ema : AbstractBase
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * v3);
Unsafe.Add(ref outRef, i + 3) = state.Ema;
state.TickCount += 4;
// Periodic resync to prevent floating-point drift
if (state.TickCount >= ResyncInterval)
{
state.TickCount = 0;
// For EMA, resync means recalculating from a known good state
// Since we don't store history, we accept the current state as truth
// The drift is typically < 1e-14 per operation, so after 10000 ops
// it's still well within double precision tolerance
}
}
// Scalar remainder
@@ -417,7 +399,6 @@ public sealed class Ema : AbstractBase
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * val);
Unsafe.Add(ref outRef, i) = state.Ema;
state.TickCount++;
}
}
+2 -3
View File
@@ -308,7 +308,7 @@ ema.Prime(historicalPrices); // Ready for live data
### State Structure
```csharp
private record struct State(double Ema, double E, bool IsHot, bool IsCompensated, int TickCount);
private record struct State(double Ema, double E, bool IsHot, bool IsCompensated);
```
| Field | Size | Purpose |
@@ -317,9 +317,8 @@ private record struct State(double Ema, double E, bool IsHot, bool IsCompensated
| `E` | 8 bytes | Compensator factor $(1-\alpha)^n$ |
| `IsHot` | 1 byte | Warmup complete flag |
| `IsCompensated` | 1 byte | True when E < 1e-10 |
| `TickCount` | 4 bytes | Bars processed |
**Total state:** ~32 bytes per instance. No buffers required regardless of period.
**Total state:** ~18 bytes per instance. No buffers required regardless of period. IIR filters are inherently self-correcting and do not require periodic resynchronization.
### FMA Optimization
+1 -11
View File
@@ -19,7 +19,7 @@ namespace QuanTAlib;
public sealed class Rema : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(double Rema, double PrevRema, double E, bool IsHot, bool IsCompensated, int TickCount, bool IsInitialized)
private record struct State(double Rema, double PrevRema, double E, bool IsHot, bool IsCompensated, bool IsInitialized)
{
public static State New() => new()
{
@@ -28,7 +28,6 @@ public sealed class Rema : AbstractBase
E = 1.0,
IsHot = false,
IsCompensated = false,
TickCount = 0,
IsInitialized = false
};
}
@@ -41,7 +40,6 @@ public sealed class Rema : AbstractBase
private double _lastValidValue;
private double _p_lastValidValue;
private const int ResyncInterval = 10000;
private const double COVERAGE_THRESHOLD = 0.05;
private const double COMPENSATOR_THRESHOLD = 1e-10;
@@ -229,7 +227,6 @@ public sealed class Rema : AbstractBase
state.Rema = input;
state.PrevRema = input;
state.IsInitialized = true;
state.TickCount = 1;
state.E *= decay;
if (state.E <= COVERAGE_THRESHOLD)
@@ -256,7 +253,6 @@ public sealed class Rema : AbstractBase
// When lambda=0: REMA = reg_component (pure momentum)
state.Rema = Math.FusedMultiplyAdd(lambda, emaComponent - regComponent, regComponent);
state.PrevRema = prevRema;
state.TickCount++;
if (!state.IsCompensated)
{
@@ -318,7 +314,6 @@ public sealed class Rema : AbstractBase
state.Rema = val;
state.PrevRema = val;
state.IsInitialized = true;
state.TickCount = 1;
state.E *= decay;
if (state.E <= COVERAGE_THRESHOLD)
@@ -336,7 +331,6 @@ public sealed class Rema : AbstractBase
double regComponent = state.Rema + (state.Rema - state.PrevRema);
state.Rema = Math.FusedMultiplyAdd(lambda, emaComponent - regComponent, regComponent);
state.PrevRema = prevRema;
state.TickCount++;
if (!state.IsCompensated)
{
@@ -365,10 +359,6 @@ public sealed class Rema : AbstractBase
Unsafe.Add(ref outRef, i) = result;
if (state.TickCount >= ResyncInterval)
{
state.TickCount = 0;
}
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ REMA is inherently recursive due to state dependency on previous two values. SIM
| **Throughput (Streaming)** | ~2 ns/bar | Single Update() call |
| **Allocations (Hot Path)** | 0 bytes | Verified via BenchmarkDotNet |
| **Complexity** | O(1) | Two FMA operations per bar |
| **State Size** | 48 bytes | REMA, PrevRema, E, flags, counter |
| **State Size** | 44 bytes | REMA, PrevRema, E, flags |
### Quality Metrics
+3 -3
View File
@@ -627,11 +627,11 @@ public class RemaTests
[Fact]
public void Rema_AllModes_ProduceSameResult_AfterResyncInterval()
{
// This guards against implementation drift between CalculateCore (batch/span)
// and Update(TValue) (streaming/eventing) when internal counters wrap/reset.
// Guards against implementation drift between CalculateCore (batch/span)
// and Update(TValue) (streaming/eventing) over long runs.
int period = 10;
double lambda = 0.5;
int count = 12050; // > ResyncInterval (10,000)
int count = 12050; // Long-running consistency check
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 321);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+2 -15
View File
@@ -23,9 +23,9 @@ namespace QuanTAlib;
public sealed class Rgma : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(double E, bool IsHot, bool IsInitialized, int TickCount)
private record struct State(double E, bool IsHot, bool IsInitialized)
{
public static State New() => new() { E = 1.0, IsHot = false, IsInitialized = false, TickCount = 0 };
public static State New() => new() { E = 1.0, IsHot = false, IsInitialized = false };
}
private readonly int _passes;
@@ -45,7 +45,6 @@ public sealed class Rgma : AbstractBase
private bool _disposed;
private const double COVERAGE_THRESHOLD = 0.05;
private const int ResyncInterval = 10000;
private const int StackAllocThreshold = 512;
public override bool IsHot => _state.IsHot;
@@ -272,7 +271,6 @@ public sealed class Rgma : AbstractBase
{
filters.Fill(input);
state.IsInitialized = true;
state.TickCount = 1;
state.E *= decay;
if (state.E <= COVERAGE_THRESHOLD)
{
@@ -289,17 +287,12 @@ public sealed class Rgma : AbstractBase
filters[i] = Math.FusedMultiplyAdd(alpha, filters[i - 1] - filters[i], filters[i]);
}
state.TickCount++;
state.E *= decay;
if (!state.IsHot && state.E <= COVERAGE_THRESHOLD)
{
state.IsHot = true;
}
if (state.TickCount >= ResyncInterval)
{
state.TickCount = 0;
}
return filters[^1];
}
@@ -332,7 +325,6 @@ public sealed class Rgma : AbstractBase
{
filters.Fill(x);
state.IsInitialized = true;
state.TickCount = 1;
state.E *= decay;
if (state.E <= COVERAGE_THRESHOLD)
{
@@ -349,17 +341,12 @@ public sealed class Rgma : AbstractBase
filters[p] = Math.FusedMultiplyAdd(alpha, filters[p - 1] - filters[p], filters[p]);
}
state.TickCount++;
state.E *= decay;
if (!state.IsHot && state.E <= COVERAGE_THRESHOLD)
{
state.IsHot = true;
}
if (state.TickCount >= ResyncInterval)
{
state.TickCount = 0;
}
y = filters[^1];
}