// COPPOCK: Coppock Curve // WMA of the sum of two Rate-of-Change values at different lookback periods. // Formula: Coppock = WMA(ROC(longRoc) + ROC(shortRoc), wmaPeriod) // Source: Edwin Coppock, "A Guide to the Use of Coppock Curve", Barron's (1962) using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// COPPOCK: Coppock Curve /// /// /// The Coppock Curve applies a Weighted Moving Average to the sum of two /// Rate-of-Change calculations at different lookback periods, producing a /// zero-centered oscillator. Zero-line crossovers from below signal long-term /// buying opportunities on monthly charts. /// /// Calculation: /// 1. ROC_long = (price / price[longRoc] - 1) * 100 /// 2. ROC_short = (price / price[shortRoc] - 1) * 100 /// 3. Combined = ROC_long + ROC_short /// 4. Coppock = WMA(Combined, wmaPeriod) /// /// Default parameters: longRoc=14, shortRoc=11, wmaPeriod=10 (original monthly values) /// WarmupPeriod = max(longRoc, shortRoc) + wmaPeriod - 1 /// /// Sources: /// - Coppock, E.S.C. (1962). "A Guide to the Use of Coppock Curve." Barron's /// - Kirkpatrick, C. & Dahlquist, J. (2010). Technical Analysis, Chapter 15 /// [SkipLocalsInit] public sealed class Coppock : ITValuePublisher { private const int DefaultLongRoc = 14; private const int DefaultShortRoc = 11; private const int DefaultWmaPeriod = 10; private readonly int _longRoc; private readonly int _shortRoc; private readonly int _wmaPeriod; private readonly double _wmaNorm; // W*(W+1)/2 // ROC lookback ring buffers: slot[head] = oldest price still needed // Size = period+1 so we can store current + lookback[period] simultaneously private readonly double[] _longBuf; // size = longRoc+1 private readonly double[] _shortBuf; // size = shortRoc+1 // WMA dual-running-sum ring buffer private readonly double[] _wmaBuf; // size = wmaPeriod // All scalar state grouped for _ps = _s snapshot (bar-correction). // PrevLong / PrevShort / PrevWma: slot values BEFORE the last isNew=true write, // used to restore ring-buffer slots on isNew=false rollback. [StructLayout(LayoutKind.Auto)] private record struct State( int LongHead, int ShortHead, double PrevLong, double PrevShort, int WmaHead, int WmaCount, double WmaPlainSum, double WmaWeightedSum, double PrevWma, int Count, double LastValidPrice); private State _s; private State _ps; public string Name { get; } public int WarmupPeriod { get; } public TValue Last { get; private set; } /// True when enough bars have been processed for valid output. public bool IsHot => _s.Count >= WarmupPeriod; public event TValuePublishedHandler? Pub; public Coppock(int longRoc = DefaultLongRoc, int shortRoc = DefaultShortRoc, int wmaPeriod = DefaultWmaPeriod) { if (longRoc <= 0) { throw new ArgumentException("Long ROC period must be greater than 0", nameof(longRoc)); } if (shortRoc <= 0) { throw new ArgumentException("Short ROC period must be greater than 0", nameof(shortRoc)); } if (wmaPeriod <= 0) { throw new ArgumentException("WMA period must be greater than 0", nameof(wmaPeriod)); } _longRoc = longRoc; _shortRoc = shortRoc; _wmaPeriod = wmaPeriod; _wmaNorm = wmaPeriod * (wmaPeriod + 1) * 0.5; _longBuf = new double[longRoc + 1]; _shortBuf = new double[shortRoc + 1]; _wmaBuf = new double[wmaPeriod]; // Warmup: need max(longRoc,shortRoc) bars before combined ROC is non-zero, // then wmaPeriod bars to fill WMA window. Subtract 1 for the shared bar. WarmupPeriod = Math.Max(longRoc, shortRoc) + wmaPeriod - 1; _s = default; _ps = _s; Name = $"Coppock({longRoc},{shortRoc},{wmaPeriod})"; } public Coppock(ITValuePublisher source, int longRoc = DefaultLongRoc, int shortRoc = DefaultShortRoc, int wmaPeriod = DefaultWmaPeriod) : this(longRoc, shortRoc, wmaPeriod) { source.Pub += Handle; } private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PubEvent(TValue value, bool isNew) => Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue input, bool isNew = true) { if (isNew) { _ps = _s; } else { // Restore ring-buffer slots overwritten by the last isNew=true call. _longBuf[_ps.LongHead] = _s.PrevLong; _shortBuf[_ps.ShortHead] = _s.PrevShort; _wmaBuf[_ps.WmaHead] = _s.PrevWma; _s = _ps; } // Local copy for JIT register promotion int longH = _s.LongHead; int shortH = _s.ShortHead; int wmaH = _s.WmaHead; int wmaCount = _s.WmaCount; double plainSum = _s.WmaPlainSum; double weightedSum = _s.WmaWeightedSum; int count = _s.Count; double lastValid = _s.LastValidPrice; double price = input.Value; if (!double.IsFinite(price)) { price = double.IsFinite(lastValid) ? lastValid : 0.0; } else { lastValid = price; } if (isNew) { count++; } // ── ROC lookback ring buffers ───────────────────────────────────────── // Capture slot value BEFORE writing (for restore on next isNew=false). double prevLong = _longBuf[longH]; double prevShort = _shortBuf[shortH]; _longBuf[longH] = price; _shortBuf[shortH] = price; if (isNew) { longH = (longH + 1) % (_longRoc + 1); shortH = (shortH + 1) % (_shortRoc + 1); } // ── Combined ROC ────────────────────────────────────────────────────── double rocLong = prevLong != 0.0 ? 100.0 * (price - prevLong) / prevLong : 0.0; double rocShort = prevShort != 0.0 ? 100.0 * (price - prevShort) / prevShort : 0.0; double combined = rocLong + rocShort; // ── WMA dual running sum (O(1) per bar) ─────────────────────────────── // When buffer is growing (wmaCount < wmaPeriod): // plainSum += combined // weightedSum += (wmaCount+1) * combined [1-based weight] // When buffer is full (wmaCount == wmaPeriod): // oldest evicted from slot wmaH // plainSum = plainSum - oldest + combined // weightedSum = weightedSum - (plainSum_before_eviction) + wmaPeriod * combined double prevWma = _wmaBuf[wmaH]; double coppockVal; if (wmaCount < _wmaPeriod) { plainSum += combined; wmaCount++; weightedSum += wmaCount * combined; // weight = position 1..wmaPeriod double norm = wmaCount * (wmaCount + 1) * 0.5; coppockVal = norm != 0.0 ? weightedSum / norm : 0.0; } else { double oldPlain = plainSum; plainSum = plainSum - prevWma + combined; weightedSum = weightedSum - oldPlain + _wmaPeriod * combined; coppockVal = weightedSum / _wmaNorm; } _wmaBuf[wmaH] = combined; if (isNew) { wmaH = (wmaH + 1) % _wmaPeriod; } // ── Write back state (including pre-write slot snapshots) ───────────── _s = new State( longH, shortH, prevLong, prevShort, wmaH, wmaCount, plainSum, weightedSum, prevWma, count, lastValid); Last = new TValue(input.Time, coppockVal); PubEvent(Last, isNew); return Last; } /// Updates streaming state from a and returns output series. public TSeries Update(TSeries source) { int len = source.Count; if (len == 0) { return new TSeries([], []); } var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); Batch(source.Values, CollectionsMarshal.AsSpan(v), _longRoc, _shortRoc, _wmaPeriod); source.Times.CopyTo(CollectionsMarshal.AsSpan(t)); // Prime streaming state to match end of batch Reset(); for (int i = 0; i < len; i++) { Update(new TValue(source.Times[i], source.Values[i]), isNew: true); } return new TSeries(t, v); } /// Resets all internal state. public void Reset() { Array.Clear(_longBuf); Array.Clear(_shortBuf); Array.Clear(_wmaBuf); _s = default; _ps = _s; Last = default; } // ── Static Span Batch ──────────────────────────────────────────────────── /// /// Calculates Coppock for the full source span. Uses for all intermediate buffers. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch( ReadOnlySpan source, Span output, int longRoc = DefaultLongRoc, int shortRoc = DefaultShortRoc, int wmaPeriod = DefaultWmaPeriod) { if (source.Length != output.Length) { throw new ArgumentException("Source and output must have the same length", nameof(output)); } if (longRoc <= 0) { throw new ArgumentException("Long ROC period must be greater than 0", nameof(longRoc)); } if (shortRoc <= 0) { throw new ArgumentException("Short ROC period must be greater than 0", nameof(shortRoc)); } if (wmaPeriod <= 0) { throw new ArgumentException("WMA period must be greater than 0", nameof(wmaPeriod)); } int len = source.Length; if (len == 0) { return; } int lBufSize = longRoc + 1; int sBufSize = shortRoc + 1; double wmaNorm = wmaPeriod * (wmaPeriod + 1) * 0.5; double[] longBuf = ArrayPool.Shared.Rent(lBufSize); double[] shortBuf = ArrayPool.Shared.Rent(sBufSize); double[] wmaBuf = ArrayPool.Shared.Rent(wmaPeriod); longBuf.AsSpan(0, lBufSize).Clear(); shortBuf.AsSpan(0, sBufSize).Clear(); wmaBuf.AsSpan(0, wmaPeriod).Clear(); try { int longH = 0, shortH = 0, wmaH = 0, wmaCount = 0; double plainSum = 0.0, weightedSum = 0.0; double lastValid = 0.0; for (int i = 0; i < len; i++) { double price = source[i]; if (!double.IsFinite(price)) { price = lastValid; } else { lastValid = price; } double prevLong = longBuf[longH]; double prevShort = shortBuf[shortH]; longBuf[longH] = price; shortBuf[shortH] = price; longH = (longH + 1) % lBufSize; shortH = (shortH + 1) % sBufSize; double rocLong = prevLong != 0.0 ? 100.0 * (price - prevLong) / prevLong : 0.0; double rocShort = prevShort != 0.0 ? 100.0 * (price - prevShort) / prevShort : 0.0; double combined = rocLong + rocShort; double oldest = wmaBuf[wmaH]; double coppockVal; if (wmaCount < wmaPeriod) { plainSum += combined; wmaCount++; weightedSum += wmaCount * combined; double norm = wmaCount * (wmaCount + 1) * 0.5; coppockVal = norm != 0.0 ? weightedSum / norm : 0.0; } else { double oldPlain = plainSum; plainSum = plainSum - oldest + combined; weightedSum = weightedSum - oldPlain + wmaPeriod * combined; coppockVal = weightedSum / wmaNorm; } wmaBuf[wmaH] = combined; wmaH = (wmaH + 1) % wmaPeriod; output[i] = coppockVal; } } finally { ArrayPool.Shared.Return(longBuf); ArrayPool.Shared.Return(shortBuf); ArrayPool.Shared.Return(wmaBuf); } } /// Calculates Coppock for an entire . public static TSeries Batch( TSeries source, int longRoc = DefaultLongRoc, int shortRoc = DefaultShortRoc, int wmaPeriod = DefaultWmaPeriod) { if (source == null || source.Count == 0) { return new TSeries([], []); } int len = source.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); Batch(source.Values, CollectionsMarshal.AsSpan(v), longRoc, shortRoc, wmaPeriod); source.Times.CopyTo(CollectionsMarshal.AsSpan(t)); return new TSeries(t, v); } /// Creates a Coppock indicator and calculates results for the source series. public static (TSeries Results, Coppock Indicator) Calculate( TSeries source, int longRoc = DefaultLongRoc, int shortRoc = DefaultShortRoc, int wmaPeriod = DefaultWmaPeriod) { var indicator = new Coppock(longRoc, shortRoc, wmaPeriod); var results = indicator.Update(source); return (results, indicator); } }