using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// Computes the Price Momentum Oscillator (PMO), a double-smoothed rate of change /// developed by Carl Swenlin (DecisionPoint). /// /// /// DecisionPoint PMO Algorithm: /// ROC = (Close / Close[1] - 1) × 100 (always 1-bar), /// RocEma = CustomEMA(ROC, timePeriods) × 10, /// PMO = CustomEMA(RocEma, smoothPeriods). /// /// Custom EMA uses alpha = 2/N (not the standard 2/(N+1)), and is seeded with the SMA /// of the first N values. This matches the original DecisionPoint specification and agrees /// with both Skender.Stock.Indicators and OoplesFinance implementations. /// /// PMO oscillates around zero; positive values indicate upward momentum, negative values /// indicate downward momentum. Crossings of zero or a signal line suggest trend changes. /// Non-finite inputs (NaN/±Inf) are sanitized by substituting the last finite value observed. /// /// For the authoritative algorithm reference, full rationale, and behavioral contracts, see the /// companion files in the same directory. /// /// Reference Pine Script implementation [SkipLocalsInit] public sealed class Pmo : AbstractBase { private const int DefaultTimePeriods = 35; private const int DefaultSmoothPeriods = 20; private const int DefaultSignalPeriods = 10; private readonly int _timePeriods; private readonly int _smoothPeriods; private readonly double _alpha1; private readonly double _alpha2; [StructLayout(LayoutKind.Auto)] private record struct State( double LastValid, double PrevClose, double RocEmaRaw, double Pmo, double RocSum, double RocEmaScaledSum, int RocCount, int RocEmaCount, bool HasPrevClose, bool RocEmaSeeded, bool PmoSeeded, int Bars); private State _state, _p_state; private ITValuePublisher? _source; private bool _disposed; /// /// True when the indicator has enough data to produce meaningful PMO values. /// public override bool IsHot => _state.Bars > _timePeriods + _smoothPeriods; /// /// Initializes a new PMO indicator. /// /// First EMA smoothing period for 1-bar ROC (must be >= 2) /// Second EMA smoothing period for PMO (must be >= 1) /// Signal line EMA period (reserved for future use, must be >= 1) public Pmo(int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods) { if (timePeriods < 2) { throw new ArgumentException("Time periods must be >= 2", nameof(timePeriods)); } if (smoothPeriods < 1) { throw new ArgumentException("Smooth periods must be >= 1", nameof(smoothPeriods)); } if (signalPeriods < 1) { throw new ArgumentException("Signal periods must be >= 1", nameof(signalPeriods)); } _timePeriods = timePeriods; _smoothPeriods = smoothPeriods; // DecisionPoint PMO uses custom smoothing: alpha = 2/N (not standard EMA 2/(N+1)) _alpha1 = 2.0 / _timePeriods; _alpha2 = 2.0 / _smoothPeriods; Name = $"Pmo({timePeriods},{smoothPeriods},{signalPeriods})"; WarmupPeriod = timePeriods + smoothPeriods; } /// /// Initializes a new PMO indicator with source for event-based chaining. /// public Pmo(ITValuePublisher source, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods) : this(timePeriods, smoothPeriods, signalPeriods) { _source = source; _source.Pub += HandleUpdate; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); [MethodImpl(MethodImplOptions.AggressiveInlining)] public override TValue Update(TValue input, bool isNew = true) { if (isNew) { _p_state = _state; } else { _state = _p_state; } double value = double.IsFinite(input.Value) ? input.Value : _state.LastValid; _state.LastValid = value; _state.Bars++; // Step 1: Compute 1-bar percentage ROC double roc; if (!_state.HasPrevClose) { roc = 0.0; _state.HasPrevClose = true; _state.PrevClose = value; } else { roc = _state.PrevClose != 0.0 // skipcq: CS-R1077 - Exact-zero guard: PrevClose is a price; zero means no prior data, division by zero produces Infinity ? ((value / _state.PrevClose) - 1.0) * 100.0 : 0.0; _state.PrevClose = value; } // Step 2: First Custom EMA smoothing of 1-bar ROC (SMA-seeded, alpha = 2/timePeriods) // Skender seeds at index timePeriods (after timePeriods+1 bars), using SMA of timePeriods ROC values [1..timePeriods] // For streaming: accumulate first _timePeriods ROC values (skip index 0 which has no prev close) double rocEmaScaled; if (!_state.RocEmaSeeded) { if (_state.Bars == 1) { // First bar: ROC = 0, skip for SMA accumulation (Skender starts ROC at index 1) _state.RocEmaRaw = 0.0; rocEmaScaled = 0.0; } else { // Accumulate ROC values for SMA seed _state.RocSum += roc; _state.RocCount++; if (_state.RocCount >= _timePeriods) { // SMA seed: average of first _timePeriods ROC values _state.RocEmaRaw = _state.RocSum / _timePeriods; _state.RocEmaSeeded = true; rocEmaScaled = _state.RocEmaRaw * 10.0; } else { _state.RocEmaRaw = 0.0; rocEmaScaled = 0.0; } } } else { // Custom EMA: alpha = 2/N _state.RocEmaRaw = Math.FusedMultiplyAdd(roc - _state.RocEmaRaw, _alpha1, _state.RocEmaRaw); rocEmaScaled = _state.RocEmaRaw * 10.0; } // Step 3: Second Custom EMA smoothing → PMO (SMA-seeded, alpha = 2/smoothPeriods) double pmoValue; if (!_state.RocEmaSeeded) { // Not enough data for first EMA yet pmoValue = 0.0; } else if (!_state.PmoSeeded) { // Accumulate RocEma scaled values for SMA seed _state.RocEmaScaledSum += rocEmaScaled; _state.RocEmaCount++; if (_state.RocEmaCount >= _smoothPeriods) { // SMA seed: average of first _smoothPeriods scaled RocEma values _state.Pmo = _state.RocEmaScaledSum / _smoothPeriods; _state.PmoSeeded = true; pmoValue = _state.Pmo; } else { pmoValue = 0.0; } } else { // Custom EMA: alpha = 2/N _state.Pmo = Math.FusedMultiplyAdd(rocEmaScaled - _state.Pmo, _alpha2, _state.Pmo); pmoValue = _state.Pmo; } Last = new TValue(input.Time, pmoValue); PubEvent(Last, isNew); 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); Reset(); for (int i = 0; i < len; i++) { Update(new TValue(new DateTime(source.Times[i], DateTimeKind.Utc), source.Values[i]), true); tSpan[i] = source.Times[i]; vSpan[i] = Last.Value; } _p_state = _state; return new TSeries(t, v); } public override void Prime(ReadOnlySpan source, TimeSpan? step = null) { TimeSpan interval = step ?? TimeSpan.FromSeconds(1); DateTime time = DateTime.UtcNow - (interval * source.Length); for (int i = 0; i < source.Length; i++) { Update(new TValue(time, source[i]), true); time += interval; } } public static TSeries Batch(TSeries source, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods) { var indicator = new Pmo(timePeriods, smoothPeriods, signalPeriods); return indicator.Update(source); } /// /// Calculates PMO over a span of values. /// Zero-allocation method for maximum performance. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan source, Span output, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods) { if (source.Length == 0) { throw new ArgumentException("Source cannot be empty", nameof(source)); } if (output.Length < source.Length) { throw new ArgumentException("Output length must be >= source length", nameof(output)); } if (timePeriods < 2) { throw new ArgumentException("Time periods must be >= 2", nameof(timePeriods)); } if (smoothPeriods < 1) { throw new ArgumentException("Smooth periods must be >= 1", nameof(smoothPeriods)); } if (signalPeriods < 1) { throw new ArgumentException("Signal periods must be >= 1", nameof(signalPeriods)); } // DecisionPoint PMO custom smoothing: alpha = 2/N double alpha1 = 2.0 / timePeriods; double alpha2 = 2.0 / smoothPeriods; // Step 1: Compute 1-bar ROC for all bars // Step 2: First CustomEMA(ROC, timePeriods) with SMA seed, then ×10 // Step 3: Second CustomEMA(scaled, smoothPeriods) with SMA seed → PMO double rocEmaRaw = 0.0; bool rocEmaSeeded = false; double rocSum = 0.0; int rocCount = 0; double pmo = 0.0; bool pmoSeeded = false; double scaledSum = 0.0; int scaledCount = 0; for (int i = 0; i < source.Length; i++) { // 1-bar ROC double roc = i > 0 && source[i - 1] != 0.0 ? ((source[i] / source[i - 1]) - 1.0) * 100.0 : 0.0; // First Custom EMA of ROC with SMA seed double rocEmaScaled; if (!rocEmaSeeded) { if (i == 0) { // First bar: no previous close, ROC = 0, skip accumulation rocEmaScaled = 0.0; } else { rocSum += roc; rocCount++; if (rocCount >= timePeriods) { rocEmaRaw = rocSum / timePeriods; rocEmaSeeded = true; rocEmaScaled = rocEmaRaw * 10.0; } else { rocEmaScaled = 0.0; } } } else { rocEmaRaw += alpha1 * (roc - rocEmaRaw); rocEmaScaled = rocEmaRaw * 10.0; } // Second Custom EMA of scaled RocEma with SMA seed → PMO if (!rocEmaSeeded) { output[i] = 0.0; } else if (!pmoSeeded) { scaledSum += rocEmaScaled; scaledCount++; if (scaledCount >= smoothPeriods) { pmo = scaledSum / smoothPeriods; pmoSeeded = true; output[i] = pmo; } else { output[i] = 0.0; } } else { pmo += alpha2 * (rocEmaScaled - pmo); output[i] = pmo; } } } public static (TSeries Results, Pmo Indicator) Calculate(TSeries source, int timePeriods = DefaultTimePeriods, int smoothPeriods = DefaultSmoothPeriods, int signalPeriods = DefaultSignalPeriods) { var indicator = new Pmo(timePeriods, smoothPeriods, signalPeriods); TSeries results = indicator.Update(source); return (results, indicator); } public override void Reset() { _state = default; _p_state = default; Last = default; } protected override void Dispose(bool disposing) { if (!_disposed) { if (disposing && _source != null) { _source.Pub -= HandleUpdate; _source = null; } _disposed = true; } base.Dispose(disposing); } }