Files
QuanTAlib/lib/momentum/pmo/Pmo.cs
T

411 lines
14 KiB
C#

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Computes the Price Momentum Oscillator (PMO), a double-smoothed rate of change
/// developed by Carl Swenlin (DecisionPoint).
/// </summary>
/// <remarks>
/// DecisionPoint PMO Algorithm:
/// <c>ROC = (Close / Close[1] - 1) × 100</c> (always 1-bar),
/// <c>RocEma = CustomEMA(ROC, timePeriods) × 10</c>,
/// <c>PMO = CustomEMA(RocEma, smoothPeriods)</c>.
///
/// 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.
/// </remarks>
/// <seealso href="pmo.pine">Reference Pine Script implementation</seealso>
[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;
/// <summary>
/// True when the indicator has enough data to produce meaningful PMO values.
/// </summary>
public override bool IsHot => _state.Bars > _timePeriods + _smoothPeriods;
/// <summary>
/// Initializes a new PMO indicator.
/// </summary>
/// <param name="timePeriods">First EMA smoothing period for 1-bar ROC (must be >= 2)</param>
/// <param name="smoothPeriods">Second EMA smoothing period for PMO (must be >= 1)</param>
/// <param name="signalPeriods">Signal line EMA period (reserved for future use, must be >= 1)</param>
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;
}
/// <summary>
/// Initializes a new PMO indicator with source for event-based chaining.
/// </summary>
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<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);
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<double> 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);
}
/// <summary>
/// Calculates PMO over a span of values.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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);
}
}