Add R² and SMAPE error metrics with comprehensive tests and documentation

- Introduced R² (Coefficient of Determination) metric with detailed mathematical foundation, performance profile, and usage examples.
- Implemented SMAPE (Symmetric Mean Absolute Percentage Error) metric, addressing asymmetry in MAPE with symmetric error calculations.
- Added unit tests for SMAPE covering various scenarios including edge cases and input validation.
- Enhanced Dema class to correctly handle event publishing with isNew parameter.
- Updated Quantower test project to include coverage configuration for better test reporting.
This commit is contained in:
Miha Kralj
2025-12-29 20:58:21 -08:00
parent 4dbb093892
commit bf611d319f
50 changed files with 11327 additions and 21 deletions
+385
View File
@@ -0,0 +1,385 @@
namespace QuanTAlib.Tests;
public class MeTests
{
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Me(0));
Assert.Throws<ArgumentException>(() => new Me(-1));
var me = new Me(10);
Assert.NotNull(me);
}
[Fact]
public void Properties_Accessible()
{
var me = new Me(10);
Assert.Equal(0, me.Last.Value);
Assert.False(me.IsHot);
Assert.Contains("Me", me.Name, StringComparison.Ordinal);
me.Update(100, 105);
Assert.NotEqual(0, me.Last.Time);
}
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
int period = 5;
var me = new Me(period);
for (int i = 0; i < period - 1; i++)
{
Assert.False(me.IsHot, $"IsHot should be false at index {i}");
me.Update(i * 10, i * 10 + 5);
}
me.Update((period - 1) * 10, (period - 1) * 10 + 5);
Assert.True(me.IsHot, "IsHot should be true after period updates");
}
[Fact]
public void Me_CalculatesCorrectly()
{
var me = new Me(3);
// 10 - 15 = -5
var res1 = me.Update(10, 15);
Assert.Equal(-5.0, res1.Value, 10);
// 20 - 30 = -10, Mean = (-5 + -10) / 2 = -7.5
var res2 = me.Update(20, 30);
Assert.Equal(-7.5, res2.Value, 10);
// 30 - 25 = 5, Mean = (-5 + -10 + 5) / 3 = -10/3
var res3 = me.Update(30, 25);
Assert.Equal(-10.0 / 3.0, res3.Value, 10);
// 40 - 35 = 5, Window slides: (-10 + 5 + 5) / 3 = 0
var res4 = me.Update(40, 35);
Assert.Equal(0.0, res4.Value, 10);
}
[Fact]
public void Me_PerfectPrediction_ReturnsZero()
{
var me = new Me(5);
for (int i = 0; i < 10; i++)
{
me.Update(i * 10, i * 10); // Perfect prediction
}
Assert.Equal(0.0, me.Last.Value, 10);
}
[Fact]
public void Me_ConstantUnderPrediction_ReturnsPositive()
{
var me = new Me(5);
for (int i = 0; i < 10; i++)
{
me.Update(110, 100); // Actual > predicted (under-prediction)
}
Assert.Equal(10.0, me.Last.Value, 10);
}
[Fact]
public void Me_ConstantOverPrediction_ReturnsNegative()
{
var me = new Me(5);
for (int i = 0; i < 10; i++)
{
me.Update(100, 110); // Actual < predicted (over-prediction)
}
Assert.Equal(-10.0, me.Last.Value, 10);
}
[Fact]
public void Me_BalancedErrors_CancelOut()
{
var me = new Me(4);
// Errors: +10, -10, +10, -10 should cancel out
me.Update(110, 100); // +10
me.Update(90, 100); // -10
me.Update(110, 100); // +10
me.Update(90, 100); // -10
Assert.Equal(0.0, me.Last.Value, 10);
}
[Fact]
public void Me_PreservesSign()
{
var me = new Me(3);
// Error = 15 - 10 = 5 (under-prediction)
me.Update(15, 10);
Assert.True(me.Last.Value > 0, "ME should be positive for under-prediction");
var me2 = new Me(3);
// Error = 10 - 15 = -5 (over-prediction)
me2.Update(10, 15);
Assert.True(me2.Last.Value < 0, "ME should be negative for over-prediction");
}
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var me = new Me(10);
me.Update(100, 110, isNew: true);
double value1 = me.Last.Value;
me.Update(100, 120, isNew: true);
double value2 = me.Last.Value;
Assert.NotEqual(value1, value2);
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var me = new Me(10);
me.Update(100, 110);
me.Update(100, 120, isNew: true);
double beforeUpdate = me.Last.Value;
me.Update(100, 130, isNew: false);
double afterUpdate = me.Last.Value;
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var me = new Me(5);
double tenthActual = 0;
double tenthPredicted = 0;
// Feed 10 updates
for (int i = 0; i < 10; i++)
{
tenthActual = i * 10;
tenthPredicted = i * 10 + 5;
me.Update(tenthActual, tenthPredicted);
}
double stateAfterTen = me.Last.Value;
// Apply 5 corrections with isNew=false
for (int i = 0; i < 5; i++)
{
me.Update(100 + i, 200 + i, isNew: false);
}
// Restore to original values
me.Update(tenthActual, tenthPredicted, isNew: false);
Assert.Equal(stateAfterTen, me.Last.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var me = new Me(5);
for (int i = 0; i < 10; i++)
{
me.Update(i * 10, i * 10 + 5);
}
Assert.True(me.IsHot);
me.Reset();
Assert.False(me.IsHot);
Assert.Equal(0, me.Last.Value);
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var me = new Me(5);
me.Update(100, 110);
me.Update(110, 120);
me.Update(120, 130);
var result = me.Update(double.NaN, double.NaN);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var me = new Me(5);
me.Update(100, 110);
me.Update(110, 120);
var result = me.Update(double.PositiveInfinity, double.NegativeInfinity);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void MultipleNaN_ContinuesWithLastValid()
{
var me = new Me(5);
me.Update(100, 110);
me.Update(110, 120);
me.Update(120, 130);
var r1 = me.Update(double.NaN, double.NaN);
var r2 = me.Update(double.NaN, double.NaN);
var r3 = me.Update(double.NaN, double.NaN);
Assert.True(double.IsFinite(r1.Value));
Assert.True(double.IsFinite(r2.Value));
Assert.True(double.IsFinite(r3.Value));
}
[Fact]
public void Me_Throws_On_Single_Input()
{
var me = new Me(10);
Assert.Throws<NotSupportedException>(() => me.Update(new TValue(DateTime.UtcNow, 1)));
Assert.Throws<NotSupportedException>(() => me.Update(new TSeries()));
Assert.Throws<NotSupportedException>(() => me.Prime(new double[] { 1, 2, 3 }));
}
[Fact]
public void BatchSpan_MatchesStreaming()
{
int period = 5;
int count = 100;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
double[] actual = new double[count];
double[] predicted = new double[count];
for (int i = 0; i < count; i++)
{
var bar = gbm.Next();
actual[i] = bar.Close;
predicted[i] = bar.Close * 1.05 + 2; // Offset prediction
}
// Streaming
var me = new Me(period);
var streamingResults = new double[count];
for (int i = 0; i < count; i++)
{
streamingResults[i] = me.Update(actual[i], predicted[i]).Value;
}
// Batch
double[] batchResults = new double[count];
Me.Batch(actual, predicted, batchResults, period);
// Compare
for (int i = 0; i < count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], 9);
}
}
[Fact]
public void BatchSpan_ValidatesInput()
{
double[] actual = [1, 2, 3, 4, 5];
double[] predicted = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
double[] wrongSizePredicted = new double[3];
// Period must be > 0
Assert.Throws<ArgumentException>(() =>
Me.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() =>
Me.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), -1));
// Output must be same length as source
Assert.Throws<ArgumentException>(() =>
Me.Batch(actual.AsSpan(), predicted.AsSpan(), wrongSizeOutput.AsSpan(), 3));
// Predicted must be same length as actual
Assert.Throws<ArgumentException>(() =>
Me.Batch(actual.AsSpan(), wrongSizePredicted.AsSpan(), output.AsSpan(), 3));
}
[Fact]
public void Calculate_Works()
{
var actual = new TSeries();
var predicted = new TSeries();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
actual.Add(now.AddMinutes(i), i * 10);
predicted.Add(now.AddMinutes(i), i * 10 + 5);
}
var results = Me.Calculate(actual, predicted, 3);
Assert.Equal(10, results.Count);
// All errors are -5, so ME should be -5
Assert.Equal(-5.0, results.Last.Value, 10);
}
[Fact]
public void Calculate_ValidatesMismatchedLengths()
{
var actual = new TSeries();
var predicted = new TSeries();
for (int i = 0; i < 10; i++) actual.Add(DateTime.UtcNow, i);
for (int i = 0; i < 5; i++) predicted.Add(DateTime.UtcNow, i);
Assert.Throws<ArgumentException>(() => Me.Calculate(actual, predicted, 3));
}
[Fact]
public void BatchSpan_HandlesNaN()
{
double[] actual = [100, 110, double.NaN, 130, 140];
double[] predicted = [105, 115, 125, double.NaN, 145];
double[] output = new double[5];
Me.Batch(actual, predicted, output, 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
}
}
[Fact]
public void Me_Resync_Works()
{
var me = new Me(5);
// Force many updates to trigger resync (ResyncInterval = 1000)
for (int i = 0; i < 1100; i++)
{
me.Update(110, 100); // Constant error of +10
}
// After resync, result should still be correct
Assert.Equal(10.0, me.Last.Value, 10);
}
}
+220
View File
@@ -0,0 +1,220 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ME: Mean Error (also known as Mean Bias Error)
/// </summary>
/// <remarks>
/// ME measures the average error between actual and predicted values,
/// preserving the sign to indicate systematic bias in predictions.
///
/// Formula:
/// ME = (1/n) * Σ(actual - predicted)
///
/// Key properties:
/// - Can be positive or negative
/// - Positive ME indicates under-prediction (actual > predicted)
/// - Negative ME indicates over-prediction (actual &lt; predicted)
/// - ME = 0 indicates no systematic bias (but not necessarily accurate predictions)
/// - Errors can cancel out, hiding large individual errors
/// </remarks>
[SkipLocalsInit]
public sealed class Me : AbstractBase
{
private readonly RingBuffer _buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(double Sum, double LastValidActual, double LastValidPredicted, int TickCount);
private State _state;
private State _p_state;
private const int ResyncInterval = 1000;
public Me(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_buffer = new RingBuffer(period);
Name = $"Me({period})";
WarmupPeriod = period;
}
public override bool IsHot => _buffer.IsFull;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue actual, TValue predicted, bool isNew = true)
{
double actualVal = actual.Value;
double predictedVal = predicted.Value;
if (!double.IsFinite(actualVal))
actualVal = double.IsFinite(_state.LastValidActual) ? _state.LastValidActual : 0.0;
else
_state.LastValidActual = actualVal;
if (!double.IsFinite(predictedVal))
predictedVal = double.IsFinite(_state.LastValidPredicted) ? _state.LastValidPredicted : 0.0;
else
_state.LastValidPredicted = predictedVal;
double error = actualVal - predictedVal; // Preserves sign!
if (isNew)
{
_p_state = _state;
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_state.Sum = _state.Sum - removedValue + error;
_buffer.Add(error);
_state.TickCount++;
if (_buffer.IsFull && _state.TickCount >= ResyncInterval)
{
_state.TickCount = 0;
_state.Sum = _buffer.RecalculateSum();
}
}
else
{
_state = _p_state;
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_state.Sum = _state.Sum - removedValue + error;
_buffer.UpdateNewest(error);
_state.Sum = _buffer.RecalculateSum();
}
double result = _buffer.Count > 0 ? _state.Sum / _buffer.Count : error;
Last = new TValue(actual.Time, result);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(double actual, double predicted, bool isNew = true)
{
return Update(new TValue(DateTime.UtcNow, actual), new TValue(DateTime.UtcNow, predicted), isNew);
}
public override TValue Update(TValue input, bool isNew = true)
{
throw new NotSupportedException("ME requires two inputs. Use Update(actual, predicted).");
}
public override TSeries Update(TSeries source)
{
throw new NotSupportedException("ME requires two inputs. Use Calculate(actualSeries, predictedSeries, period).");
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
throw new NotSupportedException("ME requires two inputs.");
}
public override void Reset()
{
_buffer.Clear();
_state = default;
_p_state = default;
Last = default;
}
public static TSeries Calculate(TSeries actual, TSeries predicted, int period)
{
if (actual.Count != predicted.Count)
throw new ArgumentException("Actual and predicted series must have the same length", nameof(predicted));
int len = actual.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(actual.Values, predicted.Values, vSpan, period);
actual.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> actual, ReadOnlySpan<double> predicted, Span<double> output, int period)
{
if (actual.Length != predicted.Length || actual.Length != output.Length)
throw new ArgumentException("All spans must have the same length", nameof(output));
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
int len = actual.Length;
if (len == 0) return;
const int StackAllocThreshold = 256;
Span<double> buffer = period <= StackAllocThreshold
? stackalloc double[period]
: new double[period];
double sum = 0;
double lastValidActual = 0;
double lastValidPredicted = 0;
for (int k = 0; k < len; k++)
{
if (double.IsFinite(actual[k])) { lastValidActual = actual[k]; break; }
}
for (int k = 0; k < len; k++)
{
if (double.IsFinite(predicted[k])) { lastValidPredicted = predicted[k]; break; }
}
int bufferIndex = 0;
int i = 0;
int warmupEnd = Math.Min(period, len);
for (; i < warmupEnd; i++)
{
double act = actual[i];
double pred = predicted[i];
if (double.IsFinite(act)) lastValidActual = act; else act = lastValidActual;
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
double error = act - pred;
sum += error;
buffer[i] = error;
output[i] = sum / (i + 1);
}
int tickCount = 0;
for (; i < len; i++)
{
double act = actual[i];
double pred = predicted[i];
if (double.IsFinite(act)) lastValidActual = act; else act = lastValidActual;
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
double error = act - pred;
sum = sum - buffer[bufferIndex] + error;
buffer[bufferIndex] = error;
bufferIndex++;
if (bufferIndex >= period) bufferIndex = 0;
output[i] = sum / period;
tickCount++;
if (tickCount >= ResyncInterval)
{
tickCount = 0;
double recalcSum = 0;
for (int k = 0; k < period; k++) recalcSum += buffer[k];
sum = recalcSum;
}
}
}
}
+144
View File
@@ -0,0 +1,144 @@
# ME: Mean Error (Mean Bias Error)
> "Sometimes you need to know not just how wrong you are, but which direction you're wrong in."
Mean Error (ME), also known as Mean Bias Error, measures the average error between actual and predicted values while preserving the sign. Unlike MAE, ME reveals systematic bias in predictions: whether a model consistently over-predicts or under-predicts.
## Historical Context
ME is one of the fundamental error metrics in statistics and forecasting. While MAE and MSE focus on error magnitude, ME fills the critical role of detecting directional bias. A model could have low MAE but significant ME, indicating consistent over or under-prediction that cancels out when measuring magnitude alone.
## Architecture & Physics
ME preserves the sign of errors, allowing positive and negative errors to cancel each other. This makes it ideal for detecting systematic bias but unsuitable for measuring prediction accuracy alone.
### Properties
- **Can be negative**: ME can be positive, negative, or zero
- **Positive ME**: Model under-predicts (actual > predicted on average)
- **Negative ME**: Model over-predicts (actual < predicted on average)
- **Zero ME**: No systematic bias (but not necessarily accurate)
- **Same units**: ME is in the same units as the original data
- **Cancellation**: Errors can cancel out, hiding large individual errors
## Mathematical Foundation
### 1. Error Calculation
For each observation, calculate the signed difference between actual and predicted values:
$$e_i = y_i - \hat{y}_i$$
Where:
- $y_i$ = actual value
- $\hat{y}_i$ = predicted value
### 2. Mean Calculation
Average the errors over the period:
$$ME = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)$$
### 3. Running Update (O(1))
QuanTAlib uses a ring buffer with running sum for O(1) updates:
$$S_{new} = S_{old} - e_{oldest} + e_{newest}$$
$$ME = \frac{S_{new}}{n}$$
## Implementation Details
### Usage Patterns
```csharp
// Streaming mode - update with each new observation
var me = new Me(period: 20);
var result = me.Update(actualValue, predictedValue);
// Batch mode - calculate for entire series
var results = Me.Calculate(actualSeries, predictedSeries, period: 20);
// Span mode - zero-allocation for high performance
Me.Batch(actualSpan, predictedSpan, outputSpan, period: 20);
```
### Parameters
| Parameter | Type | Description |
| :--- | :--- | :--- |
| **period** | int | Lookback window for averaging (must be > 0) |
### Properties
| Property | Type | Description |
| :--- | :--- | :--- |
| **Last** | TValue | Most recent ME value |
| **IsHot** | bool | True when buffer is full |
| **Name** | string | Indicator name (e.g., "Me(20)") |
| **WarmupPeriod** | int | Number of periods before valid output |
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | ~10 ns/bar | O(1) update complexity |
| **Allocations** | 0 | Uses pre-allocated ring buffer |
| **Complexity** | O(1) | Constant time per update |
| **Accuracy** | 10/10 | Exact calculation |
| **Timeliness** | 9/10 | No lag beyond the period |
| **Smoothness** | 7/10 | Moderate smoothing |
## Interpretation
| ME Value | Interpretation |
| :--- | :--- |
| **ME > 0** | Systematic under-prediction (actual > predicted) |
| **ME = 0** | No systematic bias |
| **ME < 0** | Systematic over-prediction (actual < predicted) |
## Comparison with Other Metrics
| Metric | Shows Bias | Units | Use Case |
| :--- | :--- | :--- | :--- |
| **ME** | Yes | Same as data | Detect systematic bias |
| **MAE** | No | Same as data | Average error magnitude |
| **MSE** | No | Squared units | Penalize large errors |
| **MPE** | Yes | Percentage | Relative bias |
## Common Use Cases
1. **Bias Detection**: Identify if a model consistently over or under-predicts
2. **Model Calibration**: Use ME to adjust model outputs
3. **Forecast Evaluation**: Distinguish between random errors and systematic bias
4. **Trading Signals**: Detect directional bias in price predictions
## Warning: Cancellation Problem
ME can be misleading when errors cancel out:
```csharp
var me = new Me(4);
me.Update(110, 100); // Error: +10
me.Update(90, 100); // Error: -10
me.Update(110, 100); // Error: +10
me.Update(90, 100); // Error: -10
// ME = 0, but individual errors are large!
```
Always use ME alongside MAE or MSE to get a complete picture.
## Edge Cases
- **Identical Values**: Returns 0 when actual equals predicted
- **NaN Handling**: Uses last valid value substitution
- **Single Input**: Not supported (requires two series)
- **Period = 1**: Returns current signed error
- **Balanced Errors**: Can return 0 even with large individual errors
## Related Indicators
- [MAE](../mae/Mae.md) - Mean Absolute Error (magnitude only)
- [MSE](../mse/Mse.md) - Mean Squared Error
- [MPE](../mpe/Mpe.md) - Mean Percentage Error (relative bias)