mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-28 17:57:45 +00:00
415 lines
12 KiB
C#
415 lines
12 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace QuanTAlib;
|
|
|
|
/// <summary>
|
|
/// MdAPE: Median Absolute Percentage Error
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// MdAPE is the median of absolute percentage errors. Unlike MAPE which uses
|
|
/// the mean, MdAPE is robust to outliers in percentage terms.
|
|
///
|
|
/// Formula:
|
|
/// MdAPE = Median(|actual - predicted| / |actual|) * 100
|
|
///
|
|
/// Key properties:
|
|
/// - Robust to outliers (50% breakdown point)
|
|
/// - Scale-independent (expressed as percentage)
|
|
/// - Less sensitive to extreme percentage errors than MAPE
|
|
/// - Undefined when actual = 0 (uses epsilon protection)
|
|
/// </remarks>
|
|
[SkipLocalsInit]
|
|
public sealed class Mdape : AbstractBase
|
|
{
|
|
private readonly RingBuffer _buffer;
|
|
private readonly double[] _sortBuffer;
|
|
|
|
[StructLayout(LayoutKind.Auto)]
|
|
private record struct State(double LastValidActual, double LastValidPredicted, int TickCount);
|
|
private State _state;
|
|
private State _p_state;
|
|
|
|
public Mdape(int period)
|
|
{
|
|
if (period <= 0)
|
|
{
|
|
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
|
}
|
|
|
|
_buffer = new RingBuffer(period);
|
|
_sortBuffer = new double[period];
|
|
Name = $"Mdape({period})";
|
|
WarmupPeriod = period;
|
|
}
|
|
|
|
public override bool IsHot => _buffer.IsFull;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public TValue Update(TValue actual, TValue predicted, bool isNew = true)
|
|
{
|
|
return UpdateCore(actual.AsDateTime, actual.Value, predicted.Value, isNew);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-allocating Update overload that accepts primitive values.
|
|
/// Avoids TValue allocation in hot path.
|
|
/// </summary>
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public TValue Update(double actual, double predicted, bool isNew = true)
|
|
{
|
|
return UpdateCore(DateTime.MinValue, actual, predicted, isNew);
|
|
}
|
|
|
|
public override TValue Update(TValue input, bool isNew = true)
|
|
{
|
|
throw new NotSupportedException("MdAPE requires two inputs. Use Update(actual, predicted).");
|
|
}
|
|
|
|
public override TSeries Update(TSeries source)
|
|
{
|
|
throw new NotSupportedException("MdAPE requires two inputs. Use Batch(actualSeries, predictedSeries, period).");
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private TValue UpdateCore(DateTime time, double actualVal, double predictedVal, bool isNew)
|
|
{
|
|
// Validate actual: must be finite AND have sufficient magnitude (matches Batch logic)
|
|
if (!double.IsFinite(actualVal) || Math.Abs(actualVal) < 1e-10)
|
|
{
|
|
actualVal = double.IsFinite(_state.LastValidActual) && Math.Abs(_state.LastValidActual) >= 1e-10
|
|
? _state.LastValidActual
|
|
: 1.0;
|
|
}
|
|
else
|
|
{
|
|
_state.LastValidActual = actualVal;
|
|
}
|
|
|
|
if (!double.IsFinite(predictedVal))
|
|
{
|
|
predictedVal = double.IsFinite(_state.LastValidPredicted) ? _state.LastValidPredicted : 0.0;
|
|
}
|
|
else
|
|
{
|
|
_state.LastValidPredicted = predictedVal;
|
|
}
|
|
|
|
// Calculate absolute percentage error (absActual guaranteed >= 1e-10 by validation above)
|
|
double absActual = Math.Abs(actualVal);
|
|
double absError = Math.Abs(actualVal - predictedVal);
|
|
double percentageError = (absError / absActual) * 100.0;
|
|
|
|
if (isNew)
|
|
{
|
|
_p_state = _state;
|
|
_buffer.Add(percentageError);
|
|
_state.TickCount++;
|
|
}
|
|
else
|
|
{
|
|
_state = _p_state;
|
|
_buffer.UpdateNewest(percentageError);
|
|
}
|
|
|
|
// Calculate median
|
|
double result = CalculateMedian();
|
|
|
|
Last = new TValue(time, result);
|
|
PubEvent(Last, isNew);
|
|
return Last;
|
|
}
|
|
|
|
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
|
{
|
|
throw new NotSupportedException("MdAPE requires two inputs.");
|
|
}
|
|
|
|
public override void Reset()
|
|
{
|
|
_buffer.Clear();
|
|
_state = default;
|
|
_p_state = default;
|
|
Last = default;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private double CalculateMedian()
|
|
{
|
|
int count = _buffer.Count;
|
|
if (count == 0)
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
// Copy buffer contents to sort buffer using GetSequencedSpans to handle wraparound
|
|
_buffer.GetSequencedSpans(out var first, out var second);
|
|
first.CopyTo(_sortBuffer.AsSpan(0, first.Length));
|
|
if (second.Length > 0)
|
|
{
|
|
second.CopyTo(_sortBuffer.AsSpan(first.Length, second.Length));
|
|
}
|
|
|
|
// Sort the portion we copied
|
|
Array.Sort(_sortBuffer, 0, count);
|
|
|
|
// Calculate median
|
|
if ((count & 1) != 0)
|
|
{
|
|
return _sortBuffer[count / 2];
|
|
}
|
|
|
|
// For even count, average the two middle elements
|
|
int mid = count / 2;
|
|
return (_sortBuffer[mid - 1] + _sortBuffer[mid]) * 0.5;
|
|
}
|
|
|
|
public static TSeries Batch(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;
|
|
}
|
|
|
|
// Use dual-heap sliding median for O(log n) updates instead of O(n log n) sort per element
|
|
var slidingMedian = new SlidingMedianHeap(period);
|
|
|
|
double lastValidActual = 1.0;
|
|
double lastValidPredicted = 0;
|
|
|
|
for (int k = 0; k < len; k++)
|
|
{
|
|
if (double.IsFinite(actual[k]) && Math.Abs(actual[k]) >= 1e-10)
|
|
{
|
|
lastValidActual = actual[k];
|
|
break;
|
|
}
|
|
}
|
|
for (int k = 0; k < len; k++)
|
|
{
|
|
if (double.IsFinite(predicted[k]))
|
|
{
|
|
lastValidPredicted = predicted[k];
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < len; i++)
|
|
{
|
|
double act = actual[i];
|
|
double pred = predicted[i];
|
|
|
|
if (double.IsFinite(act) && Math.Abs(act) >= 1e-10)
|
|
{
|
|
lastValidActual = act;
|
|
}
|
|
else
|
|
{
|
|
act = lastValidActual;
|
|
}
|
|
|
|
if (double.IsFinite(pred))
|
|
{
|
|
lastValidPredicted = pred;
|
|
}
|
|
else
|
|
{
|
|
pred = lastValidPredicted;
|
|
}
|
|
|
|
double absActual = Math.Abs(act);
|
|
double absError = Math.Abs(act - pred);
|
|
double percentageError = absActual > 1e-10 ? (absError / absActual) * 100.0 : 0.0;
|
|
|
|
// Add to sliding median (handles removal of old values automatically)
|
|
slidingMedian.Add(percentageError);
|
|
|
|
// Get median in O(1)
|
|
output[i] = slidingMedian.GetMedian();
|
|
}
|
|
}
|
|
|
|
public static (TSeries Results, Mdape Indicator) Calculate(TSeries actual, TSeries predicted, int period)
|
|
{
|
|
var indicator = new Mdape(period);
|
|
TSeries results = Batch(actual, predicted, period);
|
|
return (results, indicator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dual-heap based sliding window median calculator.
|
|
/// Maintains O(log n) insert/remove and O(1) median query.
|
|
/// </summary>
|
|
private sealed class SlidingMedianHeap
|
|
{
|
|
private readonly int _windowSize;
|
|
private readonly Queue<double> _window;
|
|
private readonly SortedList<double, int> _lower; // max-heap simulation (stores smaller half)
|
|
private readonly SortedList<double, int> _upper; // min-heap simulation (stores larger half)
|
|
private int _lowerCount;
|
|
private int _upperCount;
|
|
|
|
public SlidingMedianHeap(int windowSize)
|
|
{
|
|
_windowSize = windowSize;
|
|
_window = new Queue<double>(windowSize + 1);
|
|
_lower = new SortedList<double, int>();
|
|
_upper = new SortedList<double, int>();
|
|
_lowerCount = 0;
|
|
_upperCount = 0;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Add(double value)
|
|
{
|
|
// If window is full, remove oldest element
|
|
if (_window.Count >= _windowSize)
|
|
{
|
|
double oldest = _window.Dequeue();
|
|
Remove(oldest);
|
|
}
|
|
|
|
_window.Enqueue(value);
|
|
Insert(value);
|
|
Rebalance();
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public double GetMedian()
|
|
{
|
|
if (_lowerCount == 0 && _upperCount == 0)
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
if (_lowerCount > _upperCount)
|
|
{
|
|
return _lower.Keys[_lower.Count - 1]; // max of lower
|
|
}
|
|
else if (_upperCount > _lowerCount)
|
|
{
|
|
return _upper.Keys[0]; // min of upper
|
|
}
|
|
else
|
|
{
|
|
return (_lower.Keys[_lower.Count - 1] + _upper.Keys[0]) * 0.5;
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private void Insert(double value)
|
|
{
|
|
if (_lowerCount == 0 || value <= _lower.Keys[_lower.Count - 1])
|
|
{
|
|
AddToList(_lower, value);
|
|
_lowerCount++;
|
|
}
|
|
else
|
|
{
|
|
AddToList(_upper, value);
|
|
_upperCount++;
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private void Remove(double value)
|
|
{
|
|
if (_lowerCount > 0 && value <= _lower.Keys[_lower.Count - 1])
|
|
{
|
|
RemoveFromList(_lower, value);
|
|
_lowerCount--;
|
|
}
|
|
else
|
|
{
|
|
RemoveFromList(_upper, value);
|
|
_upperCount--;
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private void Rebalance()
|
|
{
|
|
// Ensure lower has at most 1 more element than upper
|
|
while (_lowerCount > _upperCount + 1)
|
|
{
|
|
double val = _lower.Keys[_lower.Count - 1];
|
|
RemoveFromList(_lower, val);
|
|
_lowerCount--;
|
|
AddToList(_upper, val);
|
|
_upperCount++;
|
|
}
|
|
|
|
while (_upperCount > _lowerCount)
|
|
{
|
|
double val = _upper.Keys[0];
|
|
RemoveFromList(_upper, val);
|
|
_upperCount--;
|
|
AddToList(_lower, val);
|
|
_lowerCount++;
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private static void AddToList(SortedList<double, int> list, double value)
|
|
{
|
|
if (list.TryGetValue(value, out int count))
|
|
{
|
|
list[value] = count + 1;
|
|
}
|
|
else
|
|
{
|
|
list[value] = 1;
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private static void RemoveFromList(SortedList<double, int> list, double value)
|
|
{
|
|
if (list.TryGetValue(value, out int count))
|
|
{
|
|
if (count == 1)
|
|
{
|
|
list.Remove(value);
|
|
}
|
|
else
|
|
{
|
|
list[value] = count - 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |