using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// Median: Rolling Median /// /// /// The Median is the middle value of a sorted dataset. It is a robust measure of central tendency, /// less affected by outliers than the Mean (SMA). /// /// Calculation: /// 1. Maintain a sorted list of the last 'Period' values. /// 2. If Period is odd, Median = Middle Value. /// 3. If Period is even, Median = Average of the two Middle Values. /// /// Complexity: /// Update: O(N) due to maintaining sorted structure (BinarySearch + Array.Copy). /// This is significantly faster than O(N log N) full sort for each update. /// [SkipLocalsInit] public sealed class Median : AbstractBase { private readonly int _period; private readonly RingBuffer _buffer; private readonly double[] _sortedBuffer; /// /// Creates a Median indicator with the specified period. /// /// The size of the rolling window (must be > 0). public Median(int period) { if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); _period = period; _buffer = new RingBuffer(period); _sortedBuffer = new double[period]; Name = $"Median({period})"; WarmupPeriod = period; } public Median(ITValuePublisher source, int period) : this(period) { source.Pub += (item) => Update(item); } public Median(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); } /// /// True if the buffer is full. /// public override bool IsHot => _buffer.IsFull; /// /// Initializes the indicator state using the provided history. /// public override void Prime(ReadOnlySpan source) { if (source.Length == 0) return; _buffer.Clear(); int warmupLength = Math.Min(source.Length, WarmupPeriod); int startIndex = source.Length - warmupLength; for (int i = startIndex; i < source.Length; i++) { Update(new TValue(DateTime.MinValue, source[i])); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) { if (_buffer.IsFull) { double old = _buffer.Oldest; RemoveFromSorted(old); } _buffer.Add(input.Value); AddToSorted(input.Value); } else { if (_buffer.Count > 0) { double current = _buffer.Newest; RemoveFromSorted(current); // Logically reduces sorted count by 1 _buffer.UpdateNewest(input.Value); // Count unchanged AddToSorted(input.Value); // Searches reduced space, re-expands to Count } else { _buffer.Add(input.Value); AddToSorted(input.Value); } } double median; int count = _buffer.Count; if (count == 0) { median = double.NaN; } else { int mid = count / 2; median = (count % 2 != 0) ? _sortedBuffer[mid] : (_sortedBuffer[mid - 1] + _sortedBuffer[mid]) * 0.5; } Last = new TValue(input.Time, median); PubEvent(Last); return Last; } public override TSeries Update(TSeries source) { if (source.Count == 0) return []; int len = source.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); var tSpan = CollectionsMarshal.AsSpan(t); var vSpan = CollectionsMarshal.AsSpan(v); Batch(source.Values, vSpan, _period); source.Times.CopyTo(tSpan); Prime(source.Values); Last = new TValue(tSpan[len - 1], vSpan[len - 1]); return new TSeries(t, v); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AddToSorted(double value) { // Invariant: _buffer has already added the new value // validCount = elements in sortedBuffer BEFORE insertion int validCount = _buffer.Count - 1; int index = Array.BinarySearch(_sortedBuffer, 0, validCount, value); if (index < 0) index = ~index; if (index < validCount) { Array.Copy(_sortedBuffer, index, _sortedBuffer, index + 1, validCount - index); } _sortedBuffer[index] = value; } /// /// Removes a value from the sorted buffer. /// Note: For duplicate values, an arbitrary instance is removed. /// This is acceptable because duplicates are interchangeable for median calculation. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RemoveFromSorted(double value) { int validCount = _buffer.Count; int index = Array.BinarySearch(_sortedBuffer, 0, validCount, value); if (index < 0) { return; } if (index < validCount - 1) { Array.Copy(_sortedBuffer, index + 1, _sortedBuffer, index, validCount - 1 - index); } } /// /// Calculates Median for the entire series using a new instance. /// public static TSeries Batch(TSeries source, int period) { var median = new Median(period); return median.Update(source); } /// /// Calculates Median in-place. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan source, Span output, int period) { if (source.Length != output.Length) throw new ArgumentException("Source and output must have the same length"); if (period <= 0) throw new ArgumentException("Period must be greater than 0", nameof(period)); int len = source.Length; if (len == 0) return; double[] sortedBuffer = new double[period]; double[] window = new double[period]; int windowIdx = 0; int count = 0; for (int i = 0; i < len; i++) { double val = source[i]; if (count == period) { double old = window[windowIdx]; int oldIndex = Array.BinarySearch(sortedBuffer, 0, count, old); // Only remove if value was found (should always be true in correct operation) if (oldIndex >= 0 && oldIndex < count - 1) { Array.Copy(sortedBuffer, oldIndex + 1, sortedBuffer, oldIndex, count - 1 - oldIndex); } count--; } window[windowIdx] = val; windowIdx = (windowIdx + 1) % period; int newIndex = Array.BinarySearch(sortedBuffer, 0, count, val); if (newIndex < 0) newIndex = ~newIndex; if (newIndex < count) { Array.Copy(sortedBuffer, newIndex, sortedBuffer, newIndex + 1, count - newIndex); } sortedBuffer[newIndex] = val; count++; int mid = count / 2; double median = (count % 2 != 0) ? sortedBuffer[mid] : (sortedBuffer[mid - 1] + sortedBuffer[mid]) * 0.5; output[i] = median; } } /// /// Resets the indicator state. /// public override void Reset() { _buffer.Clear(); Last = default; } }