diff --git a/.github/instructions/codacy.instructions.md b/.github/instructions/codacy.instructions.md
index 0773cb32..7429440c 100644
--- a/.github/instructions/codacy.instructions.md
+++ b/.github/instructions/codacy.instructions.md
@@ -10,7 +10,7 @@ Configuration for AI behavior when interacting with Codacy's MCP Server
- ALWAYS use:
- provider: gh
- organization: mihakralj
- - repository: pinescript
+ - repository: QuanTAlib
- Avoid calling `git remote -v` unless really necessary
## CRITICAL: After ANY successful `edit_file` or `reapply` operation
diff --git a/Directory.Build.props b/Directory.Build.props
index c56b673f..231a3e0c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -88,6 +88,14 @@
+
+
+ true
+
+
@@ -108,9 +116,10 @@
+
Z:\Quantower
- $([System.IO.Directory]::GetDirectories("$(QuantowerRoot)\TradingPlatform", "v1*")[0])
diff --git a/_sidebar.md b/_sidebar.md
index 6a0e10f2..560093b3 100644
--- a/_sidebar.md
+++ b/_sidebar.md
@@ -20,9 +20,12 @@
* [HAMMA - Hamming MA](/lib/trends_FIR/hamma/Hamma.md)
* [HANMA - Hanning MA](/lib/trends_FIR/hanma/Hanma.md)
* [HMA - Hull MA](/lib/trends_FIR/hma/Hma.md)
- * [HWMA - Henderson Weighted MA](/lib/trends_FIR/hwma/Hwma.md)
* [LSMA - Least Squares MA](/lib/trends_FIR/lsma/Lsma.md)
+ * [NLMA - Non-Lag MA](/lib/trends_FIR/nlma/Nlma.md)
+ * [NYQMA - Nyquist MA](/lib/trends_FIR/nyqma/Nyqma.md)
+ * [PMA - Predictive Moving Average](/lib/trends_FIR/pma/Pma.md)
* [PWMA - Pascal Weighted MA](/lib/trends_FIR/pwma/Pwma.md)
+ * [RAIN - Rainbow MA](/lib/trends_FIR/rain/Rain.md)
* [SGMA - Savitzky-Golay MA](/lib/trends_FIR/sgma/Sgma.md)
* [SINEMA - Sine Weighted MA](/lib/trends_FIR/sinema/Sinema.md)
* [SMA - Simple MA](/lib/trends_FIR/sma/Sma.md)
@@ -41,13 +44,15 @@
* [HEMA - Hull Exponential MA](/lib/trends_IIR/hema/Hema.md)
* [HOLT - Holt Exponential Smoothing](/lib/trends_IIR/holt/Holt.md)
* [HTIT - Ehlers Hilbert Transform Instant Trendline](/lib/trends_IIR/htit/Htit.md)
+ * [HWMA - Holt-Winters MA](/lib/trends_IIR/hwma/Hwma.md)
* [JMA - Jurik MA](/lib/trends_IIR/jma/Jma.md)
* [KAMA - Kaufman Adaptive MA](/lib/trends_IIR/kama/Kama.md)
+ * [LEMA - Leader EMA](/lib/trends_IIR/lema/Lema.md)
* [MAMA - Ehlers MESA Adaptive MA](/lib/trends_IIR/mama/Mama.md)
* [MAVP - Moving Average Variable Period](/lib/trends_IIR/mavp/Mavp.md)
+ * [MCNMA - McNicholl EMA](/lib/trends_IIR/mcnma/Mcnma.md)
* [MGDI - McGinley Dynamic](/lib/trends_IIR/mgdi/Mgdi.md)
* [MMA - Modified MA](/lib/trends_IIR/mma/Mma.md)
- * [PMA - Predictive Moving Average](/lib/trends_IIR/pma/Pma.md)
* [QEMA - Quadruple Exponential MA](/lib/trends_IIR/qema/Qema.md)
* [REMA - Regularized Exponential MA](/lib/trends_IIR/rema/Rema.md)
* [REVERSEEMA - Reverse EMA](/lib/trends_IIR/reverseema/ReverseEma.md)
@@ -55,7 +60,6 @@
* [RMA - Rolling MA](/lib/trends_IIR/rma/Rma.md)
* [T3 - Tillson T3 MA](/lib/trends_IIR/t3/T3.md)
* [TEMA - Triple Exponential MA](/lib/trends_IIR/tema/Tema.md)
- * [TRENDFLEX - Ehlers Trendflex](/lib/trends_IIR/trendflex/Trendflex.md)
* [VAMA - Volatility Adjusted MA](/lib/trends_IIR/vama/Vama.md)
* [VIDYA - Variable Index Dynamic Average](/lib/trends_IIR/vidya/Vidya.md)
* [YZVAMA - Yang-Zhang Volatility Adjusted MA](/lib/trends_IIR/yzvama/Yzvama.md)
@@ -65,7 +69,6 @@
* **Filters**
* [Overview](/lib/filters/_index.md)
- * [AGC - Ehlers Automatic Gain Control](/lib/filters/agc/Agc.md)
* [ALAGUERRE - Ehlers Adaptive Laguerre Filter](/lib/filters/alaguerre/ALaguerre.md)
* [BAXTERKING - Baxter-King Band-Pass Filter](/lib/filters/baxterking/BaxterKing.md)
* [CFITZ - Christiano-Fitzgerald Filter](/lib/filters/cfitz/Cfitz.md)
@@ -134,10 +137,12 @@
* [INERTIA - Inertia](/lib/oscillators/inertia/Inertia.md)
* [KDJ - KDJ Indicator](/lib/oscillators/kdj/Kdj.md)
* [PGO - Pretty Good Oscillator](/lib/oscillators/pgo/Pgo.md)
+ * [REFLEX - Ehlers Reflex](/lib/oscillators/reflex/Reflex.md)
* [SMI - Stochastic Momentum Index](/lib/oscillators/smi/Smi.md)
* [STOCH - Stochastic Oscillator](/lib/oscillators/stoch/Stoch.md)
* [STOCHF - Stochastic Fast](/lib/oscillators/stochf/Stochf.md)
* [STOCHRSI - Stochastic RSI](/lib/oscillators/stochrsi/Stochrsi.md)
+ * [TRENDFLEX - Ehlers Trendflex](/lib/oscillators/trendflex/Trendflex.md)
* [TRIX - Triple Exponential Average](/lib/oscillators/trix/Trix.md)
* [TTM_WAVE - TTM Wave](/lib/oscillators/ttm_wave/TtmWave.md)
* [ULTOSC - Ultimate Oscillator](/lib/oscillators/ultosc/Ultosc.md)
@@ -160,6 +165,7 @@
* [ROCR - Rate of Change Ratio](/lib/momentum/rocr/Rocr.md)
* [RSI - Relative Strength Index](/lib/momentum/rsi/Rsi.md)
* [RSX - Jurik Relative Strength X](/lib/momentum/rsx/Rsx.md)
+ * [SAM - Smoothed Adaptive Momentum](/lib/momentum/sam/Sam.md)
* [TSI - True Strength Index](/lib/momentum/tsi/Tsi.md)
* [VEL - Jurik Velocity](/lib/momentum/vel/Vel.md)
@@ -284,6 +290,7 @@
* **Numerics**
* [Overview](/lib/numerics/_index.md)
* [ACCEL - Acceleration](/lib/numerics/accel/Accel.md)
+ * [AGC - Ehlers Automatic Gain Control](/lib/numerics/agc/Agc.md)
* [CHANGE - Percentage Change](/lib/numerics/change/Change.md)
* [EXPTRANS - Exponential Transform](/lib/numerics/exptrans/Exptrans.md)
* [HIGHEST - Rolling Maximum](/lib/numerics/highest/Highest.md)
@@ -334,6 +341,8 @@
* **Cycles**
* [Overview](/lib/cycles/_index.md)
+ * [CCOR - Ehlers Correlation Cycle](/lib/cycles/ccor/Ccor.md)
+ * [CCYC - Ehlers Cyber Cycle](/lib/cycles/ccyc/Ccyc.md)
* [CG - Ehlers Center of Gravity](/lib/cycles/cg/Cg.md)
* [DSP - Ehlers Detrended Synthetic Price](/lib/cycles/dsp/Dsp.md)
* [EACP - Ehlers Autocorrelation Periodogram](/lib/cycles/eacp/Eacp.md)
diff --git a/docs/indicators.md b/docs/indicators.md
index 22c702a1..9ed27b0d 100644
--- a/docs/indicators.md
+++ b/docs/indicators.md
@@ -47,7 +47,10 @@ Finite Impulse Response filters. Output depends only on a fixed window of inputs
| [**HAMMA**](../lib/trends_FIR/hamma/Hamma.md) | Hamming Weighted MA | Spectral analysis window |
| [**HANMA**](../lib/trends_FIR/hanma/Hanma.md) | Hanning Weighted MA | Cosine-based window |
| [**HMA**](../lib/trends_FIR/hma/Hma.md) | Hull MA | Reduced lag via WMA differencing |
-| [**HWMA**](../lib/trends_FIR/hwma/Hwma.md) | Henderson Weighted MA | Henderson curve smoothing |
+| [**NLMA**](../lib/trends_FIR/nlma/Nlma.md) | Non-Lag MA | Damped cosine kernel FIR |
+| [**NYQMA**](../lib/trends_FIR/nyqma/Nyqma.md) | Nyquist MA | Dual LWMA cascade FIR |
+| [**PMA**](../lib/trends_FIR/pma/Pma.md) | Predictive Moving Average | Ehlers WMA cascade + extrapolation |
+| [**RAIN**](../lib/trends_FIR/rain/Rain.md) | Rainbow MA | 10× cascaded SMA |
| [**LSMA**](../lib/trends_FIR/lsma/Lsma.md) | Least Squares MA | Linear regression endpoint |
| [**PWMA**](../lib/trends_FIR/pwma/Pwma.md) | Pascal Weighted MA | Binomial coefficient weights |
| [**SGMA**](../lib/trends_FIR/sgma/Sgma.md) | Savitzky-Golay MA | Polynomial smoothing |
@@ -78,7 +81,8 @@ Infinite Impulse Response filters. Output depends on current input and past outp
| [**MAVP**](../lib/trends_IIR/mavp/Mavp.md) | Moving Average Variable Period | Per-bar dynamic period EMA |
| [**MGDI**](../lib/trends_IIR/mgdi/Mgdi.md) | McGinley Dynamic | Market-speed tracking |
| [**MMA**](../lib/trends_IIR/mma/Mma.md) | Modified MA | Smoothed EMA variant |
-| [**PMA**](../lib/trends_IIR/pma/Pma.md) | Predictive Moving Average | Ehlers super smoother + extrapolation |
+| [**NMA**](../lib/trends_IIR/nma/Nma.md) | Natural MA | Volatility-weighted sqrt-kernel adaptation (Sloman) |
+| [**HWMA**](../lib/trends_IIR/hwma/Hwma.md) | Holt-Winters MA | Triple exponential smoothing (IIR) |
| [**QEMA**](../lib/trends_IIR/qema/Qema.md) | Quad Exponential MA | Four-stage exponential |
| [**REMA**](../lib/trends_IIR/rema/Rema.md) | Regularized Exponential MA | Regularization for stability |
| [**REVERSEEMA**](../lib/trends_IIR/reverseema/ReverseEma.md) | Reverse EMA | Inverse EMA deconvolution |
@@ -86,7 +90,7 @@ Infinite Impulse Response filters. Output depends on current input and past outp
| [**RMA**](../lib/trends_IIR/rma/Rma.md) | WildeR MA | Wilder's smoothing (1/n decay) |
| [**T3**](../lib/trends_IIR/t3/T3.md) | Tillson T3 MA | Six-stage DEMA variant |
| [**TEMA**](../lib/trends_IIR/tema/Tema.md) | Triple Exponential MA | Three-stage lag reduction |
-| [**TRENDFLEX**](../lib/trends_IIR/trendflex/Trendflex.md) | Ehlers Trendflex | Zero-lag sum-of-differences trend |
+| [**TRAMA**](../lib/trends_IIR/trama/Trama.md) | Trend Regularity Adaptive MA | HH/LL frequency-based adaptation |
| [**VAMA**](../lib/trends_IIR/vama/Vama.md) | Volatility Adjusted MA | ATR-based adaptation |
| [**VIDYA**](../lib/trends_IIR/vidya/Vidya.md) | Variable Index Dynamic | CMO-based adaptation |
| [**YZVAMA**](../lib/trends_IIR/yzvama/Yzvama.md) | Yang-Zhang Vol Adjusted MA | YZ volatility adaptation |
@@ -100,7 +104,6 @@ Signal processing filters adapted for financial time series. Designed to separat
| Indicator | Full Name | Notes |
| :-------- | :-------- | :---- |
-| [**AGC**](../lib/filters/agc/Agc.md) | Ehlers Automatic Gain Control | Ehlers amplitude normalization via peak tracking |
| [**ALAGUERRE**](../lib/filters/alaguerre/ALaguerre.md) | Ehlers Adaptive Laguerre Filter | Ehlers variable-alpha from tracking error |
| [**BAXTERKING**](../lib/filters/baxterking/BaxterKing.md) | Baxter-King Band-Pass Filter | Symmetric FIR band-pass for cycle extraction |
| [**CFITZ**](../lib/filters/cfitz/Cfitz.md) | Christiano-Fitzgerald Filter | Asymmetric full-sample band-pass, random-walk optimal |
@@ -152,10 +155,12 @@ Bounded indicators that oscillate around a centerline or between fixed extremes.
| [**INERTIA**](../lib/oscillators/inertia/Inertia.md) | Inertia | Linear regression residual |
| [**KDJ**](../lib/oscillators/kdj/Kdj.md) | KDJ Indicator | Enhanced Stochastic (J = 3K − 2D) |
| [**PGO**](../lib/oscillators/pgo/Pgo.md) | Pretty Good Oscillator | ATR-normalized SMA displacement |
+| [**REFLEX**](../lib/oscillators/reflex/Reflex.md) | Ehlers Reflex | Zero-centered reversal oscillator |
| [**SMI**](../lib/oscillators/smi/Smi.md) | Stochastic Momentum Index | Distance from range midpoint (K/D lines) |
| [**STOCH**](../lib/oscillators/stoch/Stoch.md) | Stochastic Oscillator | Close within N-period H/L range (%K/%D) |
| [**STOCHF**](../lib/oscillators/stochf/Stochf.md) | Stochastic Fast | Unsmoothed Stochastic (%K/%D, SMA smoothing only) |
| [**STOCHRSI**](../lib/oscillators/stochrsi/Stochrsi.md) | Stochastic RSI | Stochastic applied to RSI (%K/%D) |
+| [**TRENDFLEX**](../lib/oscillators/trendflex/Trendflex.md) | Ehlers Trendflex | Zero-lag sum-of-differences trend oscillator |
| [**TRIX**](../lib/oscillators/trix/Trix.md) | Triple Exponential Average | ROC of triple-smoothed EMA |
| [**TTM_WAVE**](../lib/oscillators/ttm_wave/TtmWave.md) | TTM Wave | Fibonacci-period MACD composite (A/B/C waves) |
| [**ULTOSC**](../lib/oscillators/ultosc/Ultosc.md) | Ultimate Oscillator | Multi-timeframe weighted buying pressure |
@@ -206,6 +211,7 @@ Rate of change and velocity measurements. First derivatives of price.
| [**ROCR**](../lib/momentum/rocr/Rocr.md) | Rate of Change Ratio | Price ratio over N periods |
| [**RSI**](../lib/momentum/rsi/Rsi.md) | Relative Strength Index | Bounded 0-100 momentum |
| [**RSX**](../lib/momentum/rsx/Rsx.md) | Jurik RSX | Smoothed RSI variant |
+| [**SAM**](../lib/momentum/sam/Sam.md) | Smoothed Adaptive Momentum | Ehlers adaptive cycle momentum |
| [**TSI**](../lib/momentum/tsi/Tsi.md) | True Strength Index | Double-smoothed momentum oscillator |
| [**VEL**](../lib/momentum/vel/Vel.md) | Jurik Velocity | Adaptive velocity |
@@ -357,6 +363,8 @@ Periodic pattern detection and dominant frequency extraction. Markets exhibit cy
| Indicator | Full Name | Notes |
| :-------- | :-------- | :---- |
+| [**CCOR**](../lib/cycles/ccor/Ccor.md) | Ehlers Correlation Cycle | Dual Pearson correlation phasor + market state |
+| [**CCYC**](../lib/cycles/ccyc/Ccyc.md) | Ehlers Cyber Cycle | 4-tap FIR + 2-pole high-pass IIR cycle extraction |
| [**CG**](../lib/cycles/cg/Cg.md) | Ehlers Center of Gravity | Ehlers cycle measurement |
| [**DSP**](../lib/cycles/dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Cycle-isolated price component |
| [**EACP**](../lib/cycles/eacp/Eacp.md) | Ehlers Autocorrelation Periodogram | Ehlers dominant cycle detection |
@@ -379,6 +387,7 @@ Mathematical transformations and derivative indicators. Building blocks for anal
| Indicator | Full Name | Notes |
| :-------- | :-------- | :---- |
| [**ACCEL**](../lib/numerics/accel/Accel.md) | Acceleration (2nd Derivative) | Change in slope |
+| [**AGC**](../lib/numerics/agc/Agc.md) | Ehlers Automatic Gain Control | Amplitude normalization via peak tracking |
| [**CHANGE**](../lib/numerics/change/Change.md) | Percentage Change | Relative price movement |
| [**EXPTRANS**](../lib/numerics/exptrans/Exptrans.md) | Exponential Transform | e^x for log-space reversal |
| [**HIGHEST**](../lib/numerics/highest/Highest.md) | Rolling Maximum | O(1) via monotonic deque |
diff --git a/docs/validation.md b/docs/validation.md
index 41f217de..4d712b14 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -48,7 +48,7 @@ No external reference exists. Implementation verified through unit tests, edge c
| **ATR Bands** | Atrbands | ✔️ | - | ✔️ | ❔ |
| **Adaptive FIR Moving Average** | [Afirma](../lib/forecasts/afirma/Afirma.md) | - | - | - | - |
| **Ehlers Adaptive Laguerre Filter** | [ALaguerre](../lib/filters/alaguerre/ALaguerre.md) | - | - | - | - |
-| **Ehlers Automatic Gain Control** | [Agc](../lib/filters/agc/Agc.md) | - | - | - | - |
+| **Ehlers Automatic Gain Control** | [Agc](../lib/numerics/agc/Agc.md) | - | - | - | - |
| **Average Daily Range** | [Adr](../lib/volatility/adr/Adr.md) | - | - | - | - |
| **Average Directional Index** | [Adx](../lib/momentum/adx/adx.md) | ✔️ | ✔️ | ✔️ | ✔️ |
| **Average Directional Movement Rating** | [Adxr](../lib/momentum/adxr/Adxr.md) | ✔️ | ✔️ | - | - |
@@ -109,6 +109,8 @@ No external reference exists. Implementation verified through unit tests, edge c
| **Ehlers Autocorrelation Periodogram** | [Eacp](../lib/cycles/eacp/eacp.md) | - | - | - | - |
| **BandPass Filter** | [Bpf](../lib/filters/bpf/Bpf.md) | - | - | - | - |
| **Ehlers Center of Gravity** | Cg | - | - | - | ❔ |
+| **Ehlers Correlation Cycle** | [Ccor](../lib/cycles/ccor/Ccor.md) | - | - | - | - |
+| **Ehlers Cyber Cycle** | [Ccyc](../lib/cycles/ccyc/Ccyc.md) | - | - | - | ❔ |
| **Ehlers Distance Coefficient Filter** | [Edcf](../lib/filters/edcf/Edcf.md) | - | - | - | - |
| **Ehlers Even Better Sinewave** | [Ebsw](../lib/cycles/ebsw/ebsw.md) | - | - | - | ❔ |
| **Ehlers Fractal Adaptive MA** | [Frama](../lib/trends_IIR/frama/Frama.md) | - | - | - | ❔ |
@@ -202,6 +204,7 @@ No external reference exists. Implementation verified through unit tests, edge c
| **Min-Max Scaling (Normalization)** | [Normalize](../lib/numerics/normalize/Normalize.md) | - | - | - | - |
| **Mode (Most Frequent)** | Mode | - | - | - | - |
| **Modified MA** | [Mma](../lib/trends_IIR/mma/Mma.md) | - | - | - | - |
+| **Natural Moving Average** | [Nma](../lib/trends_IIR/nma/Nma.md) | - | - | - | - |
| **Momentum** | Mom | ✔️ | ✔️ | ✔️ | ❔ |
| **Momentum change; 2nd derivative** | Accel | - | - | - | - |
| **Money Flow Index** | [Mfi](../lib/volume/mfi/Mfi.md) | - | - | ✔️ | ✔️ |
@@ -259,6 +262,7 @@ No external reference exists. Implementation verified through unit tests, edge c
| **R-Squared** | [RSquared](../lib/statistics/linreg/LinReg.md) | - | - | ✔️ | ❔ |
| **Savitzky-Golay Filter** | [Sgf](../lib/filters/sgf/Sgf.md) | - | - | - | - |
| **Savitzky-Golay MA** | [Sgma](../lib/trends_FIR/sgma/Sgma.md) | - | - | - | - |
+| **Smoothed Adaptive Momentum** | [Sam](../lib/momentum/sam/Sam.md) | - | - | - | - |
| **Schaff Trend Cycle** | [Stc](../lib/cycles/stc/Stc.md) | - | - | ✔️ | ❔ |
| **Simple Moving Average** | [Sma](../lib/trends/sma/sma.md) | ✔️ | ✔️ | ✔️ | ✔️ |
| **Sine-weighted MA** | [Sinema](../lib/trends_FIR/sinema/Sinema.md) | - | - | - | - |
@@ -287,6 +291,7 @@ No external reference exists. Implementation verified through unit tests, edge c
| **Triangular Moving Average** | [Trima](../lib/trends/trima/trima.md) | ✔️ | ✔️ | ✔️ | ❔ |
| **Triple Exponential Average** | [Trix](../lib/oscillators/trix/Trix.md) | ✔️ | ✔️ | ✔️ | ❔ |
| **Triple Exponential Moving Average** | [Tema](../lib/trends/tema/tema.md) | ✔️ | ✔️ | ✔️ | ❔ |
+| **Trend Regularity Adaptive MA** | [Trama](../lib/trends_IIR/trama/Trama.md) | - | - | - | - |
| **True Range** | Tr | ✔️ | ✔️ | - | - |
| **True Strength Index** | Tsi | - | - | ✔️ | ✔️ |
| **TTM Trend** | Ttm | - | - | - | - |
diff --git a/lib/_index.md b/lib/_index.md
index 243adef2..917327f5 100644
--- a/lib/_index.md
+++ b/lib/_index.md
@@ -4,10 +4,10 @@
| Category | Count | Description |
| :--- | :---: | :--- |
-| [Trends (FIR)](trends_FIR/_index.md) | 29 | Finite Impulse Response moving averages |
-| [Trends (IIR)](trends_IIR/_index.md) | 42 | Infinite Impulse Response moving averages |
-| [Filters](filters/_index.md) | 39 | Signal processing filters |
-| [Oscillators](oscillators/_index.md) | 37 | Indicators that fluctuate around a center line |
+| [Trends (FIR)](trends_FIR/_index.md) | 32 | Finite Impulse Response moving averages |
+| [Trends (IIR)](trends_IIR/_index.md) | 37 | Infinite Impulse Response moving averages |
+| [Filters](filters/_index.md) | 37 | Signal processing filters |
+| [Oscillators](oscillators/_index.md) | 40 | Indicators that fluctuate around a center line |
| [Dynamics](dynamics/_index.md) | 18 | Trend strength and direction indicators |
| [Momentum](momentum/_index.md) | 17 | Momentum-based indicators |
| [Volatility](volatility/_index.md) | 26 | Volatility estimators and indicators |
@@ -16,10 +16,10 @@
| [Channels](channels/_index.md) | 23 | Price channels and bands |
| [Cycles](cycles/_index.md) | 16 | Cycle analysis and signal processing |
| [Reversals](reversals/_index.md) | 12 | Pattern recognition and reversal detection |
-| [Forecasts](forecasts/_index.md) | 2 | Predictive indicators |
+| [Forecasts](forecasts/_index.md) | 1 | Predictive indicators |
| [Errors](errors/_index.md) | 26 | Error metrics and loss functions |
-| [Numerics](numerics/_index.md) | 29 | Mathematical transformations |
-| **Total** | **377** | |
+| [Numerics](numerics/_index.md) | 30 | Mathematical transformations |
+| **Total** | **376** | |
## All Indicators
@@ -37,7 +37,7 @@
| ADXVMA | ADX Variable MA | Trends (IIR) |
| [ADXR](dynamics/adxr/Adxr.md) | Average Directional Movement Rating | Dynamics |
| [AFIRMA](forecasts/afirma/Afirma.md) | Adaptive FIR Moving Average | Forecasts |
-| [AGC](filters/agc/Agc.md) | Ehlers Automatic Gain Control | Filters |
+| [AGC](numerics/agc/Agc.md) | Ehlers Automatic Gain Control | Numerics |
| AHRENS | Ahrens MA | Trends (IIR) |
| [ALAGUERRE](filters/alaguerre/ALaguerre.md) | Ehlers Adaptive Laguerre Filter | Filters |
| [ALLIGATOR](dynamics/alligator/Alligator.md) | Williams Alligator | Dynamics |
@@ -57,7 +57,7 @@
| [BBANDS](channels/bbands/Bbands.md) | Bollinger Bands | Channels |
| [ATRN](volatility/atrn/Atrn.md) | ATR Normalized | Volatility |
| [ATRP](volatility/atrp/Atrp.md) | ATR Percent | Volatility |
-| BBI | Bulls Bears Index | Oscillators |
+| [BBI](oscillators/bbi/Bbi.md) | Bulls Bears Index | Oscillators |
| [BBB](oscillators/bbb/Bbb.md) | Bollinger %B | Oscillators |
| [BBS](oscillators/bbs/Bbs.md) | Bollinger Band Squeeze | Oscillators |
| [BBW](volatility/bbw/Bbw.md) | Bollinger Band Width | Volatility |
@@ -76,12 +76,13 @@
| [BUTTER2](filters/butter2/Butter2.md) | Ehlers 2-Pole Butterworth Filter | Filters |
| [BUTTER3](filters/butter3/Butter3.md) | Ehlers 3-Pole Butterworth Filter | Filters |
| [BWMA](trends_FIR/bwma/Bwma.md) | Bessel-Weighted MA | Trends (FIR) |
-| CCOR | Ehlers Correlation Cycle | Cycles |
+| [CCOR](cycles/ccor/Ccor.md) | Ehlers Correlation Cycle | Cycles |
| [CCI](momentum/cci/Cci.md) | Commodity Channel Index | Momentum |
| [CCV](volatility/ccv/Ccv.md) | Close-to-Close Volatility | Volatility |
| CCYC | Ehlers Cyber Cycle | Cycles |
| [CFB](momentum/cfb/Cfb.md) | Composite Fractal Behavior | Momentum |
| [CFO](oscillators/cfo/Cfo.md) | Chande Forecast Oscillator | Oscillators |
+| [CCYC](cycles/ccyc/Ccyc.md) | Ehlers Cyber Cycle | Cycles |
| [CG](cycles/cg/Cg.md) | Ehlers Center of Gravity | Cycles |
| [CHANDELIER](reversals/chandelier/Chandelier.md) | Chandelier Exit | Reversals |
| [CHANGE](numerics/change/Change.md) | Percentage Change | Numerics |
@@ -154,7 +155,6 @@
| [HIGHEST](numerics/highest/Highest.md) | Rolling Maximum | Numerics |
| [HLV](volatility/hlv/Hlv.md) | High-Low Volatility | Volatility |
| [HOLT](trends_IIR/holt/Holt.md) | Holt Exponential Smoothing | Trends (IIR) |
-| HW | Holt-Winters Triple Smoothing | Trends (IIR) |
| [HMA](trends_FIR/hma/Hma.md) | Hull MA | Trends (FIR) |
| [HOMOD](cycles/homod/Homod.md) | Ehlers Homodyne Discriminator | Cycles |
| [HP](filters/hp/Hp.md) | Hodrick-Prescott | Filters |
@@ -168,7 +168,7 @@
| [HUBER](errors/huber/Huber.md) | Huber Loss | Errors |
| [HURST](statistics/hurst/Hurst.md) | Hurst Exponent | Statistics |
| [HV](volatility/hv/Hv.md) | Historical Volatility | Volatility |
-| [HWMA](trends_FIR/hwma/Hwma.md) | Holt-Winters MA | Trends (FIR) |
+| [HWMA](trends_IIR/hwma/Hwma.md) | Holt-Winters MA | Trends (IIR) |
| [ICHIMOKU](dynamics/ichimoku/Ichimoku.md) | Ichimoku Cloud | Dynamics |
| IFFT | Inverse Fast Fourier Transform | Numerics |
| ILRS | Integral of LinReg Slope | Trends (FIR) |
@@ -197,7 +197,7 @@
| KST | KST Oscillator | Oscillators |
| [KURTOSIS](statistics/kurtosis/Kurtosis.md) | Kurtosis | Statistics |
| [KVO](volume/kvo/Kvo.md) | Klinger Volume Oscillator | Volume |
-| LEMA | Leader EMA | Trends (IIR) |
+| [LEMA](trends_IIR/lema/Lema.md) | Leader EMA | Trends (IIR) |
| [LINEARTRANS](numerics/lineartrans/Lineartrans.md) | Linear Transform | Numerics |
| [LINREG](statistics/linreg/LinReg.md) | Linear Regression | Statistics |
| [LOESS](filters/loess/Loess.md) | LOESS Smoothing | Filters |
@@ -211,7 +211,7 @@
| [MAAPE](errors/maape/Maape.md) | Mean Arctangent APE | Errors |
| [MACD](momentum/macd/Macd.md) | Moving Average Convergence Divergence | Momentum |
| [MAE](errors/mae/Mae.md) | Mean Absolute Error | Errors |
-| MCNMA | McNicholl EMA | Trends (IIR) |
+| [MCNMA](trends_IIR/mcnma/Mcnma.md) | McNicholl EMA | Trends (IIR) |
| [MAENV](channels/maenv/maenv.md) | Moving Average Envelope | Channels |
| [MAMA](trends_IIR/mama/Mama.md) | Ehlers MESA Adaptive MA | Trends (IIR) |
| [MAVP](trends_IIR/mavp/Mavp.md) | Moving Average Variable Period | Trends (IIR) |
@@ -227,7 +227,6 @@
| [MFI](volume/mfi/Mfi.md) | Money Flow Index | Volume |
| [MGDI](trends_IIR/mgdi/Mgdi.md) | McGinley Dynamic Indicator | Trends (IIR) |
| [MIDPOINT](numerics/midpoint/Midpoint.md) | Midrange | Numerics |
-| MLP | Multilayer Perceptron | Forecasts |
| [MMA](trends_IIR/mma/Mma.md) | Modified MA | Trends (IIR) |
| MODF | Modular Filter | Filters |
| [MMCHANNEL](channels/mmchannel/Mmchannel.md) | Min-Max Channel | Channels |
@@ -241,13 +240,13 @@
| [NATR](volatility/natr/Natr.md) | Normalized ATR | Volatility |
| NORMDIST | Normal Distribution | Numerics |
| [NORMALIZE](numerics/normalize/Normalize.md) | Min-Max Normalization | Numerics |
-| NLMA | Non-Lag Moving Average | Trends (IIR) |
-| NMA | Natural Moving Average | Trends (IIR) |
+| NLMA | Non-Lag Moving Average | Trends (FIR) |
+| [NMA](trends_IIR/nma/Nma.md) | Natural Moving Average | Trends (IIR) |
| [NOTCH](filters/notch/Notch.md) | Notch Filter | Filters |
| NW | Nadaraya-Watson Kernel Regression | Filters |
| [ONEEURO](filters/oneeuro/OneEuro.md) | One Euro Filter | Filters |
| [NVI](volume/nvi/Nvi.md) | Negative Volume Index | Volume |
-| NYQMA | Nyquist MA | Trends (IIR) |
+| NYQMA | Nyquist MA | Trends (FIR) |
| [OBV](volume/obv/Obv.md) | On Balance Volume | Volume |
| [PACF](statistics/pacf/Pacf.md) | Partial Autocorrelation Function | Statistics |
| PARZEN | Parzen Window MA | Trends (FIR) |
@@ -260,7 +259,7 @@
| [PIVOTEXT](reversals/pivotext/Pivotext.md) | Extended Traditional Pivots | Reversals |
| [PIVOTFIB](reversals/pivotfib/Pivotfib.md) | Fibonacci Pivot Points | Reversals |
| [PIVOTWOOD](reversals/pivotwood/Pivotwood.md) | Woodie's Pivot Points | Reversals |
-| [PMA](trends_IIR/pma/Pma.md) | Predictive Moving Average | Trends (IIR) |
+| [PMA](trends_FIR/pma/Pma.md) | Predictive Moving Average | Trends (FIR) |
| [PMO](momentum/pmo/Pmo.md) | Price Momentum Oscillator | Momentum |
| POISSONDIST | Poisson Distribution | Numerics |
| POLYFIT | Polynomial Fitting | Statistics |
@@ -282,9 +281,9 @@
| [QUANTILE](statistics/quantile/Quantile.md) | Quantile | Statistics |
| [QUANTILELOSS](errors/quantileloss/QuantileLoss.md) | Quantile Loss | Errors |
| [RAE](errors/rae/Rae.md) | Relative Absolute Error | Errors |
-| RAIN | Rainbow MA | Trends (IIR) |
+| RAIN | Rainbow MA | Trends (FIR) |
| [REGCHANNEL](channels/regchannel/Regchannel.md) | Regression Channels | Channels |
-| REFLEX | Ehlers Reflex Indicator | Filters |
+| REFLEX | Ehlers Reflex Indicator | Oscillators |
| [RELU](numerics/relu/Relu.md) | Rectified Linear Unit | Numerics |
| [REMA](trends_IIR/rema/Rema.md) | Regularized Exponential MA | Trends (IIR) |
| [REVERSEEMA](trends_IIR/reverseema/ReverseEma.md) | Reverse EMA | Trends (IIR) |
@@ -308,6 +307,7 @@
| RWMA | Range Weighted MA | Trends (FIR) |
| [SDCHANNEL](channels/sdchannel/Sdchannel.md) | Standard Deviation Channel | Channels |
| SAK | Ehlers Swiss Army Knife | Filters |
+| [SAM](momentum/sam/Sam.md) | Smoothed Adaptive Momentum | Momentum |
| SAM | Smoothed Adaptive Momentum | Momentum |
| [SGF](filters/sgf/Sgf.md) | Savitzky-Golay Filter | Filters |
| [SGMA](trends_FIR/sgma/Sgma.md) | Savitzky-Golay MA | Trends (FIR) |
@@ -347,8 +347,8 @@
| [THEIL](statistics/theil/Theil.md) | Theil Index | Statistics |
| [THEILU](errors/theilu/Theilu.md) | Theil's U Statistic | Errors |
| [TR](volatility/tr/Tr.md) | True Range | Volatility |
-| TRAMA | Trend Regularity Adaptive MA | Trends (IIR) |
-| [TRENDFLEX](trends_IIR/trendflex/Trendflex.md) | Ehlers Trendflex | Trends (IIR) |
+| [TRAMA](trends_IIR/trama/Trama.md) | Trend Regularity Adaptive MA | Trends (IIR) |
+| [TRENDFLEX](oscillators/trendflex/Trendflex.md) | Ehlers Trendflex | Oscillators |
| TRIM | Trimmed Mean MA | Statistics |
| [TRIMA](trends_FIR/trima/Trima.md) | Triangular MA | Trends (FIR) |
| [TSF](trends_FIR/tsf/Tsf.md) | Time Series Forecast | Trends (FIR) |
diff --git a/lib/channels/abber/abber.md b/lib/channels/abber/abber.md
index 471e30b0..a83e7262 100644
--- a/lib/channels/abber/abber.md
+++ b/lib/channels/abber/abber.md
@@ -82,6 +82,35 @@ function ABBER(source, ma_line, period, multiplier):
| `lower` | Lower aberration band |
| `avg_dev` | Current average absolute deviation (band half-width before scaling) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+ABBER maintains two running-sum ring buffers (SMA of price and SMA of absolute deviations), each updated in $O(1)$:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (oldest from running sum) | 2 | 1 | 2 |
+| ADD (new value to running sum) | 2 | 1 | 2 |
+| DIV (sum / count, two SMAs) | 2 | 15 | 30 |
+| SUB (price - prevMiddle) | 1 | 1 | 1 |
+| ABS (deviation) | 1 | 1 | 1 |
+| MUL (multiplier × avgDev) | 1 | 3 | 3 |
+| ADD/SUB (middle ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **11** | — | **~41 cycles** |
+
+Warmup overhead is negligible: the ring buffer tracks count, adding one CMP per bar until full.
+
+### Batch Mode (SIMD Analysis)
+
+The running-sum SMA is inherently sequential (each bar depends on the previous running sum). SIMD parallelization across bars is not possible for the core SMA path:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Band arithmetic (middle ± k × dev) | Vectorizable across output array with `Vector` |
+| ABS of deviations | Vectorizable with `Vector.Abs` for batch deviation pass |
+| Running-sum maintenance | Sequential; cannot parallelize |
+
## Resources
- **Pham-Gia, T. & Hung, T.L.** "The Mean and Median Absolute Deviations." *Mathematical and Computer Modelling*, 34(7-8), 2001. (MAD vs. standard deviation theory)
diff --git a/lib/channels/accbands/accbands.md b/lib/channels/accbands/accbands.md
index eb699650..dabd7e78 100644
--- a/lib/channels/accbands/accbands.md
+++ b/lib/channels/accbands/accbands.md
@@ -83,6 +83,36 @@ $$\text{Close}_t > \text{Upper}_t \quad \text{AND} \quad \text{Close}_{t-1} > \t
| `lower` | SMA of adjusted lows (support envelope) |
| `middle` | SMA of close (center line) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+ACCBANDS computes per-bar normalized width, two adjusted prices, and three independent SMA running sums:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD (H + L for denom) | 1 | 1 | 1 |
+| SUB (H - L for range) | 1 | 1 | 1 |
+| DIV (range / denom for w) | 1 | 15 | 15 |
+| MUL (factor × w) | 1 | 3 | 3 |
+| MUL (H × (1 + F·w), L × (1 - F·w)) | 2 | 3 | 6 |
+| SUB (oldest from 3 running sums) | 3 | 1 | 3 |
+| ADD (new value to 3 running sums) | 3 | 1 | 3 |
+| DIV (sum / count, three SMAs) | 3 | 15 | 45 |
+| **Total (hot)** | **15** | — | **~77 cycles** |
+
+The three DIV operations dominate. When the denominator is zero ($H + L = 0$), a branch sets $w = 0$, adding one CMP.
+
+### Batch Mode (SIMD Analysis)
+
+The three SMA running sums are sequential. The per-bar width computation ($w$, adjusted prices) is independent across bars and vectorizable in a batch pre-pass:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Width + adjusted price computation | Vectorizable with `Vector` (ADD, SUB, MUL, DIV) |
+| Three SMA running sums | Sequential; cannot parallelize across bars |
+| Band output assembly | Trivial; already scalar from SMA |
+
## Resources
- **Headley, P.** *Big Trends in Trading*. Wiley, 2002. (Original Acceleration Bands specification)
diff --git a/lib/channels/apchannel/apchannel.md b/lib/channels/apchannel/apchannel.md
index 5d4095ce..9ca9e542 100644
--- a/lib/channels/apchannel/apchannel.md
+++ b/lib/channels/apchannel/apchannel.md
@@ -90,6 +90,32 @@ function APCHANNEL(high, low, alpha):
| `lower` | Exponentially smoothed low (support) |
| `middle` | Arithmetic mean of upper and lower |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+APCHANNEL is pure IIR with no buffers. Two independent EMA updates plus a midpoint:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| FMA (decay × Upper + α × H) | 1 | 4 | 4 |
+| FMA (decay × Lower + α × L) | 1 | 4 | 4 |
+| ADD (Upper + Lower) | 1 | 1 | 1 |
+| MUL (× 0.5 for midpoint) | 1 | 3 | 3 |
+| **Total (hot)** | **4** | — | **~12 cycles** |
+
+No warmup overhead. First bar initializes directly from input, adding one CMP.
+
+### Batch Mode (SIMD Analysis)
+
+Both EMA recursions are state-dependent ($\text{Upper}_t$ depends on $\text{Upper}_{t-1}$), preventing SIMD parallelization across bars:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| FMA instructions | Already using 2 FMAs per bar; hardware-accelerated |
+| State locality | Upper + Lower fit in 2 registers; zero cache pressure |
+| Midpoint computation | Vectorizable in a post-pass across output arrays |
+
## Resources
- **Wilder, J.W.** *New Concepts in Technical Trading Systems*. Trend Research, 1978. (EMA smoothing foundations)
diff --git a/lib/channels/apz/apz.md b/lib/channels/apz/apz.md
index 33b00ea7..8948c39b 100644
--- a/lib/channels/apz/apz.md
+++ b/lib/channels/apz/apz.md
@@ -113,6 +113,46 @@ function APZ(source, high, low, period, multiplier):
| `upper` | Center + scaled adaptive range (overbought zone) |
| `lower` | Center - scaled adaptive range (oversold zone) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+APZ runs four EMA updates (double-smoothed price + double-smoothed range) plus warmup compensation and band arithmetic:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (H - L for range) | 1 | 1 | 1 |
+| FMA (EMA1 price) | 1 | 4 | 4 |
+| FMA (EMA2 price → center) | 1 | 4 | 4 |
+| FMA (EMA1 range) | 1 | 4 | 4 |
+| FMA (EMA2 range → smoothRange) | 1 | 4 | 4 |
+| MUL (multiplier × smoothRange) | 1 | 3 | 3 |
+| ADD/SUB (center ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **8** | — | **~22 cycles** |
+
+During warmup (compensator active):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (e × β²) | 1 | 3 | 3 |
+| SUB (1 - e) | 1 | 1 | 1 |
+| DIV (center / compensator) | 1 | 15 | 15 |
+| DIV (smoothRange / compensator) | 1 | 15 | 15 |
+| CMP (e > threshold) | 1 | 1 | 1 |
+| **Warmup overhead** | **5** | — | **~35 cycles** |
+
+**Total during warmup:** ~57 cycles/bar; **Post-warmup:** ~22 cycles/bar.
+
+### Batch Mode (SIMD Analysis)
+
+All four EMA recursions are state-dependent, preventing SIMD parallelization across bars:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| FMA instructions | 4 hardware FMAs per bar; no software emulation |
+| State locality | 4 EMA states + compensator fit in registers |
+| Band arithmetic | Vectorizable in a post-pass across output arrays |
+
## Resources
- **Leibfarth, L.** "Trading With An Adaptive Price Zone." *Technical Analysis of Stocks & Commodities*, September 2006. (Original APZ specification)
diff --git a/lib/channels/atrbands/atrbands.md b/lib/channels/atrbands/atrbands.md
index 4cd6b782..fb0559c0 100644
--- a/lib/channels/atrbands/atrbands.md
+++ b/lib/channels/atrbands/atrbands.md
@@ -91,6 +91,48 @@ function ATRBANDS(source, high, low, close, period, multiplier):
| `upper` | Middle + scaled ATR (volatility-adjusted resistance) |
| `lower` | Middle - scaled ATR (volatility-adjusted support) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+ATRBANDS combines an SMA running sum (center line), True Range computation, and Wilder's RMA with warmup compensation:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (oldest from SMA sum) | 1 | 1 | 1 |
+| ADD (new to SMA sum) | 1 | 1 | 1 |
+| DIV (SMA = sum / count) | 1 | 15 | 15 |
+| SUB (H - L) | 1 | 1 | 1 |
+| SUB + ABS (H - prevC, L - prevC) | 2 | 2 | 4 |
+| CMP (max of 3 for TR) | 2 | 1 | 2 |
+| FMA (RMA: prev×(n-1)/n + TR/n) | 1 | 4 | 4 |
+| MUL (multiplier × ATR) | 1 | 3 | 3 |
+| ADD/SUB (middle ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **12** | — | **~33 cycles** |
+
+During warmup (compensator active):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (e × (1 - α)) | 1 | 3 | 3 |
+| SUB (1 - e) | 1 | 1 | 1 |
+| DIV (raw_rma / (1 - e)) | 1 | 15 | 15 |
+| CMP (e > ε) | 1 | 1 | 1 |
+| **Warmup overhead** | **4** | — | **~20 cycles** |
+
+**Total during warmup:** ~53 cycles/bar; **Post-warmup:** ~33 cycles/bar.
+
+### Batch Mode (SIMD Analysis)
+
+The SMA running sum and RMA recursion are both sequential. True Range computation is independent per bar and vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| True Range (3-way max) | Vectorizable with `Vector.Max` and `Vector.Abs` |
+| RMA recursion | Sequential (IIR dependency) |
+| SMA running sum | Sequential |
+| Band arithmetic | Vectorizable in a post-pass |
+
## Resources
- **Wilder, J.W.** *New Concepts in Technical Trading Systems*. Trend Research, 1978. (Original ATR and Wilder's Smoothing)
diff --git a/lib/channels/bbands/bbands.md b/lib/channels/bbands/bbands.md
index e0091693..a990e06d 100644
--- a/lib/channels/bbands/bbands.md
+++ b/lib/channels/bbands/bbands.md
@@ -102,6 +102,38 @@ function BBANDS(source, period, multiplier):
| `bandwidth` | $[0, \infty)$ | Normalized volatility; low values signal "squeeze" |
| `percentB` | typically $[0, 1]$ | $> 1$: above upper band; $< 0$: below lower band |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+BBANDS maintains running sums of $x$ and $x^2$ via a circular buffer for $O(1)$ mean and variance:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (source² for sumSq) | 1 | 3 | 3 |
+| SUB (oldest from sum, sumSq) | 2 | 1 | 2 |
+| ADD (new to sum, sumSq) | 2 | 1 | 2 |
+| DIV (sum / count for mean) | 1 | 15 | 15 |
+| MUL (mean² for variance) | 1 | 3 | 3 |
+| DIV (sumSq / count) | 1 | 15 | 15 |
+| SUB (sumSq/n - mean²) | 1 | 1 | 1 |
+| SQRT (σ from variance) | 1 | 20 | 20 |
+| MUL (k × σ) | 1 | 3 | 3 |
+| ADD/SUB (middle ± dev) | 2 | 1 | 2 |
+| **Total (hot)** | **13** | — | **~66 cycles** |
+
+The SQRT dominates. Derived metrics (%B, BandWidth) add 2 DIV + 2 SUB (~34 cycles) when requested.
+
+### Batch Mode (SIMD Analysis)
+
+The running-sum maintenance is sequential. The variance and SQRT are per-bar and parallelizable in a batch post-pass:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Running sum/sumSq | Sequential (sliding window dependency) |
+| Variance → SQRT → bands | Vectorizable with `Vector.SquareRoot` across output |
+| %B and BandWidth derivations | Vectorizable (element-wise arithmetic) |
+
## Resources
- **Bollinger, J.** *Bollinger on Bollinger Bands*. McGraw-Hill, 2001. (Definitive reference)
diff --git a/lib/channels/dchannel/dchannel.md b/lib/channels/dchannel/dchannel.md
index 02d3972c..02806fe7 100644
--- a/lib/channels/dchannel/dchannel.md
+++ b/lib/channels/dchannel/dchannel.md
@@ -78,6 +78,34 @@ function DCHANNEL(high, low, period):
| `lower` | Lowest low over the lookback (support) |
| `middle` | Midpoint of channel (trend bias) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+DCHANNEL uses two monotonic deques for $O(1)$ amortized sliding-window max/min:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (expire stale front, max deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, max deque) | ~1 avg | 1 | 1 |
+| CMP (expire stale front, min deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, min deque) | ~1 avg | 1 | 1 |
+| ADD (upper + lower) | 1 | 1 | 1 |
+| MUL (× 0.5 for middle) | 1 | 3 | 3 |
+| **Total (amortized)** | **~6** | — | **~8 cycles** |
+
+Each element enters and exits each deque exactly once over the full series, so worst-case per-bar is $O(n)$ but amortized cost is $O(1)$. Memory: two deques of up to $n$ index entries + two circular buffers of $n$ values.
+
+### Batch Mode (SIMD Analysis)
+
+Monotonic deques are inherently sequential (deque state depends on insertion order). No SIMD parallelization across bars is possible:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Deque operations | Sequential; amortized O(1) already optimal |
+| Midpoint computation | Vectorizable in a post-pass with `Vector` |
+| Memory layout | Circular buffers are cache-friendly for sequential access |
+
## Resources
- **Donchian, R.** "High Finance in Copper." *Financial Analysts Journal*, 16(6), 1960. (Original channel concept)
diff --git a/lib/channels/decaychannel/decaychannel.md b/lib/channels/decaychannel/decaychannel.md
index 5e1dbb07..8041d9f3 100644
--- a/lib/channels/decaychannel/decaychannel.md
+++ b/lib/channels/decaychannel/decaychannel.md
@@ -107,6 +107,39 @@ function DECAYCHANNEL(high, low, period):
| `upper` | Decayed high (resistance that fades with time) |
| `lower` | Decayed low (support that fades with time) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+DECAYCHANNEL scans the circular buffer for Donchian bounds ($O(n)$) plus exponential decay computation:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (scan buffer for max, $n$ bars) | $n$ | 1 | $n$ |
+| CMP (scan buffer for min, $n$ bars) | $n$ | 1 | $n$ |
+| CMP (H ≥ currentMax, snap check) | 1 | 1 | 1 |
+| CMP (L ≤ currentMin, snap check) | 1 | 1 | 1 |
+| MUL (-λ × age) | 2 | 3 | 6 |
+| EXP (e^{-λ·age}, two bands) | 2 | 25 | 50 |
+| SUB (1 - exp result) | 2 | 1 | 2 |
+| MUL + SUB (decay × distance) | 2 | 4 | 8 |
+| ADD (midpoint) | 1 | 1 | 1 |
+| MUL (× 0.5) | 1 | 3 | 3 |
+| CMP (clamp to Donchian) | 2 | 1 | 2 |
+| **Total** | **$2n + 14$** | — | **~$2n + 74$ cycles** |
+
+For period 100: ~274 cycles/bar. The two EXP calls and the $O(n)$ Donchian scan dominate.
+
+### Batch Mode (SIMD Analysis)
+
+The Donchian scan is vectorizable for max/min reduction. The decay computation per bar depends on mutable age counters, limiting parallelism:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Donchian max/min scan | Vectorizable with `Vector.Max` / `Vector.Min` reduction |
+| EXP computation | Sequential (depends on age state) |
+| Decay application + clamping | Sequential (depends on currentMax/Min state) |
+
## Resources
- **Rutherford, E.** "Radioactive Substances and their Radiations." Cambridge University Press, 1913. (Exponential decay / half-life mathematics)
diff --git a/lib/channels/fcb/fcb.md b/lib/channels/fcb/fcb.md
index 8d8d6c8a..1e0df65f 100644
--- a/lib/channels/fcb/fcb.md
+++ b/lib/channels/fcb/fcb.md
@@ -86,6 +86,34 @@ function FCB(high, low, period):
| `upper` | Highest confirmed fractal high over lookback (structural resistance) |
| `lower` | Lowest confirmed fractal low over lookback (structural support) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+FCB combines 3-bar fractal detection ($O(1)$) with two monotonic deques for sliding-window max/min of fractal values:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (H[t-1] > H[t-2]) | 1 | 1 | 1 |
+| CMP (H[t-1] > H[t]) | 1 | 1 | 1 |
+| CMP (L[t-1] < L[t-2]) | 1 | 1 | 1 |
+| CMP (L[t-1] < L[t]) | 1 | 1 | 1 |
+| Deque ops (max, amortized) | ~2 | 1 | 2 |
+| Deque ops (min, amortized) | ~2 | 1 | 2 |
+| **Total (amortized)** | **~8** | — | **~8 cycles** |
+
+The fractal detection requires retaining 3 bars of H and L history (6 values). Between fractals, only the deque expiry/push operations execute. Fractal confirmation adds one assignment per detected fractal.
+
+### Batch Mode (SIMD Analysis)
+
+Fractal detection involves comparisons that could theoretically be vectorized, but the conditional fractal-value tracking and deque operations are sequential:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Fractal detection (4 comparisons) | Vectorizable with `Vector.GreaterThan` / `Vector.LessThan` |
+| Deque max/min maintenance | Sequential (amortized O(1) already optimal) |
+| Fractal value persistence | Sequential (conditional state update) |
+
## Resources
- **Williams, B.** *Trading Chaos*. Wiley, 1995. (Original fractal definition for markets)
diff --git a/lib/channels/jbands/jbands.md b/lib/channels/jbands/jbands.md
index 7e9047eb..27cd4c1f 100644
--- a/lib/channels/jbands/jbands.md
+++ b/lib/channels/jbands/jbands.md
@@ -123,6 +123,39 @@ function JBANDS(source, period, phase):
| `upper` | Adaptive upper envelope (snaps up, decays down) |
| `lower` | Adaptive lower envelope (snaps down, decays up) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+JBANDS is the most complex channel indicator, combining snap-and-decay bands, a two-stage volatility estimator, and a 2-pole JMA IIR filter:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB + ABS (local deviation, 2 distances) | 3 | 1 | 3 |
+| CMP (max of 2 for dLocal) | 1 | 1 | 1 |
+| SMA update (10-bar highD, running sum) | 3 | 1 | 3 |
+| Partial sort (128-bar trimmed mean) | ~900 | 1 | ~900 |
+| DIV (ratio = distance / dRef) | 2 | 15 | 30 |
+| POW (ratio^Pexp) | 2 | 30 | 60 |
+| SQRT (√d) | 2 | 20 | 40 |
+| POW (sqrtDiv^√d for adapt) | 2 | 30 | 60 |
+| MUL + SUB (snap-decay, 2 bands) | 4 | 3 | 12 |
+| JMA IIR (3 recursion stages) | ~8 | 4 | 32 |
+| **Total** | **~930** | — | **~1141 cycles** |
+
+The 128-element trimmed mean (partial sort) dominates. In practice, the sort operates on a cache-friendly 1 KB buffer, making actual latency lower than raw cycle count suggests. The JMA IIR adds ~32 cycles per bar, comparable to a double-EMA.
+
+### Batch Mode (SIMD Analysis)
+
+The JMA IIR and snap-decay bands are recursive, preventing SIMD parallelization across bars. The trimmed mean sort is $O(n \log n)$ on a fixed 128-element buffer:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Trimmed mean | Fixed 128 elements; fits in L1 cache; intrinsics-friendly sort |
+| JMA 2-pole IIR | Sequential (3-stage recursion) |
+| Snap-and-decay bands | Sequential (conditional state updates) |
+| POW/SQRT computations | Hardware-accelerated; no vectorization opportunity |
+
## Resources
- **Jurik, M.** Jurik Research. (Proprietary JMA specification and band logic)
diff --git a/lib/channels/kchannel/kchannel.md b/lib/channels/kchannel/kchannel.md
index b6653dee..2b6fa278 100644
--- a/lib/channels/kchannel/kchannel.md
+++ b/lib/channels/kchannel/kchannel.md
@@ -100,6 +100,48 @@ function KCHANNEL(source, high, low, close, period, multiplier):
| `upper` | EMA + scaled ATR (dynamic resistance) |
| `lower` | EMA - scaled ATR (dynamic support) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+KCHANNEL combines an EMA with warmup compensation (center), True Range computation, and Wilder's RMA with warmup compensation (ATR):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| FMA (EMA: α×source + (1-α)×prev) | 1 | 4 | 4 |
+| FMA (weight accumulator update) | 1 | 4 | 4 |
+| DIV (raw / weight for EMA) | 1 | 15 | 15 |
+| SUB (H - L) | 1 | 1 | 1 |
+| SUB + ABS (H - prevC, L - prevC) | 2 | 2 | 4 |
+| CMP (max of 3 for TR) | 2 | 1 | 2 |
+| FMA (RMA: prev×(n-1)/n + TR/n) | 1 | 4 | 4 |
+| MUL (multiplier × ATR) | 1 | 3 | 3 |
+| ADD/SUB (EMA ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **12** | — | **~39 cycles** |
+
+During warmup (RMA compensator active):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (e × (1 - α)) | 1 | 3 | 3 |
+| SUB (1 - e) | 1 | 1 | 1 |
+| DIV (raw_rma / (1 - e)) | 1 | 15 | 15 |
+| CMP (e > ε) | 1 | 1 | 1 |
+| **Warmup overhead** | **4** | — | **~20 cycles** |
+
+**Total during warmup:** ~59 cycles/bar; **Post-warmup:** ~39 cycles/bar.
+
+### Batch Mode (SIMD Analysis)
+
+All IIR recursions (EMA, RMA) are state-dependent, preventing SIMD parallelization across bars:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| FMA instructions | 3 hardware FMAs per bar |
+| True Range computation | Vectorizable in a batch pre-pass |
+| Band arithmetic | Vectorizable in a post-pass |
+| No buffers | Zero allocation; all state fits in registers |
+
## Resources
- **Keltner, C.** *How to Make Money in Commodities*. 1960. (Original channel concept)
diff --git a/lib/channels/maenv/maenv.md b/lib/channels/maenv/maenv.md
index 247ca90c..9ac11170 100644
--- a/lib/channels/maenv/maenv.md
+++ b/lib/channels/maenv/maenv.md
@@ -82,6 +82,46 @@ function MAENV(source, period, percentage, ma_type):
| `upper` | MA + fixed percentage (overbought threshold) |
| `lower` | MA - fixed percentage (oversold threshold) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+MAENV complexity depends on the MA type. Band arithmetic is identical for all three:
+
+**SMA mode** (type = 0):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (oldest from running sum) | 1 | 1 | 1 |
+| ADD (new to running sum) | 1 | 1 | 1 |
+| DIV (sum / count for SMA) | 1 | 15 | 15 |
+| MUL (middle × pct/100) | 1 | 3 | 3 |
+| ADD/SUB (middle ± distance) | 2 | 1 | 2 |
+| **Total (SMA, hot)** | **6** | — | **~22 cycles** |
+
+**EMA mode** (type = 1):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| FMA (EMA update) | 1 | 4 | 4 |
+| FMA (weight accumulator) | 1 | 4 | 4 |
+| DIV (raw / weight) | 1 | 15 | 15 |
+| MUL (middle × pct/100) | 1 | 3 | 3 |
+| ADD/SUB (middle ± distance) | 2 | 1 | 2 |
+| **Total (EMA, hot)** | **6** | — | **~28 cycles** |
+
+**WMA mode** (type = 2): $O(n)$ weighted sum per bar, ~$4n + 20$ cycles.
+
+### Batch Mode (SIMD Analysis)
+
+SMA and EMA modes are sequential (running sum or IIR dependency). Band arithmetic is vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Band arithmetic (middle × pct ± dist) | Vectorizable with `Vector` in batch post-pass |
+| SMA running sum / EMA recursion | Sequential |
+| WMA weighted sum | Partially vectorizable with `Vector.Multiply` + reduction |
+
## Resources
- **Murphy, J.J.** *Technical Analysis of the Financial Markets*. New York Institute of Finance, 1999. (Moving average envelope fundamentals)
diff --git a/lib/channels/mmchannel/mmchannel.md b/lib/channels/mmchannel/mmchannel.md
index 51510363..fe12d138 100644
--- a/lib/channels/mmchannel/mmchannel.md
+++ b/lib/channels/mmchannel/mmchannel.md
@@ -87,6 +87,32 @@ Each element is pushed to the deque exactly once and popped at most once (either
| $U_t - L_t$ contracting | Consolidation; range tightening |
| $U_t - L_t$ expanding | Volatility expansion; breakout potential |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+MMCHANNEL uses two monotonic deques for $O(1)$ amortized sliding-window max/min with no midpoint calculation:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (expire stale front, max deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, max deque) | ~1 avg | 1 | 1 |
+| CMP (expire stale front, min deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, min deque) | ~1 avg | 1 | 1 |
+| **Total (amortized)** | **~4** | — | **~4 cycles** |
+
+MMCHANNEL is the lightest channel indicator — no midpoint computation, no band arithmetic. Each element enters and exits each deque exactly once over the full series.
+
+### Batch Mode (SIMD Analysis)
+
+Monotonic deques are inherently sequential. No SIMD parallelization across bars is possible:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Deque operations | Sequential; amortized O(1) already optimal |
+| No midpoint/band math | Nothing to vectorize in a post-pass |
+| Memory layout | Circular buffers are cache-friendly for sequential access |
+
## Resources
- Donchian, R. (1960). "High Finance in Copper." *Financial Analysts Journal*, 16(6).
diff --git a/lib/channels/pchannel/pchannel.md b/lib/channels/pchannel/pchannel.md
index 60faa12b..6f84429c 100644
--- a/lib/channels/pchannel/pchannel.md
+++ b/lib/channels/pchannel/pchannel.md
@@ -108,6 +108,34 @@ function pchannel(high[], low[], period):
| $U_t - L_t$ contracting | Consolidation; range tightening |
| $U_t - L_t$ expanding | Volatility expansion |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+PCHANNEL uses two monotonic deques for $O(1)$ amortized sliding-window max/min plus a midpoint:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (expire stale front, max deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, max deque) | ~1 avg | 1 | 1 |
+| CMP (expire stale front, min deque) | 1 | 1 | 1 |
+| CMP (remove dominated back, min deque) | ~1 avg | 1 | 1 |
+| ADD (upper + lower) | 1 | 1 | 1 |
+| MUL (× 0.5 for middle) | 1 | 3 | 3 |
+| **Total (amortized)** | **~6** | — | **~8 cycles** |
+
+Identical to DCHANNEL in cost. Each element enters and exits each deque exactly once over the full series, yielding $O(N)$ total work across $N$ bars regardless of period.
+
+### Batch Mode (SIMD Analysis)
+
+Monotonic deques are inherently sequential. No SIMD parallelization across bars is possible:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Deque operations | Sequential; amortized O(1) already optimal |
+| Midpoint computation | Vectorizable in a post-pass with `Vector` |
+| Memory layout | Two circular buffers + two deques; cache-friendly |
+
## Resources
- Donchian, R. (1960). "High Finance in Copper." *Financial Analysts Journal*.
diff --git a/lib/channels/regchannel/regchannel.md b/lib/channels/regchannel/regchannel.md
index 993be5c8..9066512b 100644
--- a/lib/channels/regchannel/regchannel.md
+++ b/lib/channels/regchannel/regchannel.md
@@ -135,6 +135,38 @@ function regchannel(source[], period, multiplier):
| Price at lower band | Overextended below trend |
| Band width expanding | Increasing residual dispersion; trend becoming noisy |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+REGCHANNEL requires two $O(n)$ passes per bar: one for regression sums, one for residual standard deviation:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD (sum_y accumulation, pass 1) | $n$ | 1 | $n$ |
+| FMA (i × y for sum_xy, pass 1) | $n$ | 4 | $4n$ |
+| MUL + DIV (slope, intercept) | 4 | ~9 | 36 |
+| FMA (slope × i + intercept, pass 2) | $n$ | 4 | $4n$ |
+| SUB (residual = y - predicted) | $n$ | 1 | $n$ |
+| MUL (residual², pass 2) | $n$ | 3 | $3n$ |
+| ADD (ssr accumulation, pass 2) | $n$ | 1 | $n$ |
+| DIV (ssr / n) | 1 | 15 | 15 |
+| SQRT (σ) | 1 | 20 | 20 |
+| MUL + ADD/SUB (bands) | 3 | ~5 | 15 |
+| **Total** | **~$7n + 9$** | — | **~$14n + 86$ cycles** |
+
+For period 20: ~366 cycles/bar. The two window scans dominate. Index sums $\sum x$ and $\sum x^2$ are precomputed constants.
+
+### Batch Mode (SIMD Analysis)
+
+Both passes iterate over a contiguous ring buffer, making them prime candidates for SIMD vectorization:
+
+| Operation | Scalar Ops | SIMD Ops (AVX-512) | Speedup |
+| :--- | :---: | :---: | :---: |
+| Pass 1: sum_y, sum_xy | $2n$ | $n/8$ | ~16× |
+| Pass 2: residuals + squared sum | $4n$ | $n/2$ | ~8× |
+| Slope/intercept/bands | 9 | 9 | 1× |
+
## Resources
- Raff, G. (1991). "Trading the Regression Channel." *Technical Analysis of Stocks & Commodities*.
diff --git a/lib/channels/sdchannel/sdchannel.md b/lib/channels/sdchannel/sdchannel.md
index 31b640e3..cbfb957b 100644
--- a/lib/channels/sdchannel/sdchannel.md
+++ b/lib/channels/sdchannel/sdchannel.md
@@ -138,6 +138,38 @@ function sdchannel(source[], period, multiplier):
| $\sigma \to 0$ | Perfect linear trend; bands collapse |
| Band width expanding | Increasing noise around the trend |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+SDCHANNEL is algorithmically identical to REGCHANNEL — two $O(n)$ passes per bar:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD (sum_y accumulation, pass 1) | $n$ | 1 | $n$ |
+| FMA (i × y for sum_xy, pass 1) | $n$ | 4 | $4n$ |
+| MUL + DIV (slope, intercept) | 4 | ~9 | 36 |
+| FMA (slope × i + intercept, pass 2) | $n$ | 4 | $4n$ |
+| SUB (residual = y - predicted) | $n$ | 1 | $n$ |
+| MUL (residual², pass 2) | $n$ | 3 | $3n$ |
+| ADD (ssr accumulation, pass 2) | $n$ | 1 | $n$ |
+| DIV (ssr / n) | 1 | 15 | 15 |
+| SQRT (σ) | 1 | 20 | 20 |
+| MUL + ADD/SUB (bands) | 3 | ~5 | 15 |
+| **Total** | **~$7n + 9$** | — | **~$14n + 86$ cycles** |
+
+For period 20: ~366 cycles/bar. Identical performance characteristics to REGCHANNEL.
+
+### Batch Mode (SIMD Analysis)
+
+Both passes iterate over contiguous memory, enabling SIMD vectorization of the inner loops:
+
+| Operation | Scalar Ops | SIMD Ops (AVX-512) | Speedup |
+| :--- | :---: | :---: | :---: |
+| Pass 1: sum_y, sum_xy | $2n$ | $n/8$ | ~16× |
+| Pass 2: residuals + squared sum | $4n$ | $n/2$ | ~8× |
+| Slope/intercept/bands | 9 | 9 | 1× |
+
## Resources
- Raff, G. (1991). "Trading the Regression Channel." *Technical Analysis of Stocks & Commodities*.
diff --git a/lib/channels/starchannel/starchannel.md b/lib/channels/starchannel/starchannel.md
index cb1cf16d..1808a654 100644
--- a/lib/channels/starchannel/starchannel.md
+++ b/lib/channels/starchannel/starchannel.md
@@ -129,6 +129,48 @@ function starchannel(source[], high[], low[], close[], period, multiplier, atr_l
| Price at lower band | Overextended below SMA by ATR measure |
| Middle band slope positive | SMA trending upward |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+STARCHANNEL combines an SMA running sum (center), True Range, and Wilder's RMA with warmup compensation — identical cost to ATRBANDS:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (oldest from SMA sum) | 1 | 1 | 1 |
+| ADD (new to SMA sum) | 1 | 1 | 1 |
+| DIV (SMA = sum / count) | 1 | 15 | 15 |
+| SUB (H - L) | 1 | 1 | 1 |
+| SUB + ABS (H - prevC, L - prevC) | 2 | 2 | 4 |
+| CMP (max of 3 for TR) | 2 | 1 | 2 |
+| FMA (RMA: prev×(n-1)/n + TR/n) | 1 | 4 | 4 |
+| MUL (multiplier × ATR) | 1 | 3 | 3 |
+| ADD/SUB (middle ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **12** | — | **~33 cycles** |
+
+During warmup (RMA compensator active):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (e × (1 - α)) | 1 | 3 | 3 |
+| SUB (1 - e) | 1 | 1 | 1 |
+| DIV (raw_rma / (1 - e)) | 1 | 15 | 15 |
+| CMP (e > ε) | 1 | 1 | 1 |
+| **Warmup overhead** | **4** | — | **~20 cycles** |
+
+**Total during warmup:** ~53 cycles/bar; **Post-warmup:** ~33 cycles/bar.
+
+### Batch Mode (SIMD Analysis)
+
+The SMA running sum and RMA recursion are sequential. True Range computation is independent per bar:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| True Range (3-way max) | Vectorizable with `Vector.Max` and `Vector.Abs` |
+| RMA recursion | Sequential (IIR dependency) |
+| SMA running sum | Sequential |
+| Band arithmetic | Vectorizable in a post-pass |
+
## Resources
- Stoller, M. (1980s). Development of the Stoller Average Range Channel.
diff --git a/lib/channels/stbands/stbands.md b/lib/channels/stbands/stbands.md
index c1d88cd6..51528253 100644
--- a/lib/channels/stbands/stbands.md
+++ b/lib/channels/stbands/stbands.md
@@ -170,6 +170,42 @@ function stbands(high[], low[], close[], period, multiplier):
| Trend flip $-1 \to +1$ | Bullish reversal; price breached lower band |
| Band width contracting | ATR falling; volatility decreasing |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+STBANDS computes True Range, an SMA of TR via running sum, basic band math from HL2, ratchet logic, and trend determination:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| SUB (H - L) | 1 | 1 | 1 |
+| SUB + ABS (H - prevC, L - prevC) | 2 | 2 | 4 |
+| CMP (max of 3 for TR) | 2 | 1 | 2 |
+| SUB (oldest from TR sum) | 1 | 1 | 1 |
+| ADD (new to TR sum) | 1 | 1 | 1 |
+| DIV (TR sum / count for ATR) | 1 | 15 | 15 |
+| ADD (H + L for HL2) | 1 | 1 | 1 |
+| MUL (× 0.5 for HL2) | 1 | 3 | 3 |
+| MUL (k × ATR) | 1 | 3 | 3 |
+| ADD/SUB (HL2 ± k·ATR) | 2 | 1 | 2 |
+| CMP (ratchet: upper tightens?) | 2 | 1 | 2 |
+| CMP (ratchet: lower tightens?) | 2 | 1 | 2 |
+| CMP (trend: close vs bands) | 2 | 1 | 2 |
+| **Total (hot)** | **19** | — | **~39 cycles** |
+
+The ratchet logic is pure comparisons with no expensive math. The DIV for ATR is the costliest single operation.
+
+### Batch Mode (SIMD Analysis)
+
+The ATR running sum and ratchet logic are both sequential (state-dependent). True Range and basic band computation are vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| True Range (3-way max) | Vectorizable with `Vector.Max` and `Vector.Abs` |
+| HL2 + basic bands | Vectorizable in a batch pre-pass |
+| ATR running sum | Sequential |
+| Ratchet logic + trend | Sequential (conditional state) |
+
## Resources
- Seban, O. SuperTrend Indicator methodology.
diff --git a/lib/channels/ttm_lrc/TtmLrc.md b/lib/channels/ttm_lrc/TtmLrc.md
index 6abb8ccd..19dbb51a 100644
--- a/lib/channels/ttm_lrc/TtmLrc.md
+++ b/lib/channels/ttm_lrc/TtmLrc.md
@@ -129,6 +129,39 @@ function ttm_lrc(source[], period, deviations):
| $-2\sigma$ to $-1\sigma$ | ~13.5% | Oversold |
| Below $-2\sigma$ | ~2.5% | Extremely oversold relative to trend |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+TTM_LRC extends REGCHANNEL with dual bands and $R^2$ computation. Two $O(n)$ passes plus additional statistics:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD (sum_y accumulation, pass 1) | $n$ | 1 | $n$ |
+| FMA (i × y for sum_xy, pass 1) | $n$ | 4 | $4n$ |
+| MUL + DIV (slope, intercept, mean_y) | 5 | ~9 | 45 |
+| FMA (slope × i + intercept, pass 2) | $n$ | 4 | $4n$ |
+| SUB (residual, pass 2) | $n$ | 1 | $n$ |
+| MUL (residual², pass 2) | $n$ | 3 | $3n$ |
+| ADD (ssr accumulation, pass 2) | $n$ | 1 | $n$ |
+| SUB + MUL + ADD (sst, pass 2) | $2n$ | 2 | $4n$ |
+| DIV (ssr/n, sst check, R²) | 3 | 15 | 45 |
+| SQRT (σ) | 1 | 20 | 20 |
+| MUL + ADD/SUB (4 bands: ±1σ, ±kσ) | 6 | ~2 | 12 |
+| **Total** | **~$9n + 15$** | — | **~$18n + 122$ cycles** |
+
+For period 100: ~1922 cycles/bar. The longer default period (100 vs 20) makes the window scans significantly more expensive than REGCHANNEL.
+
+### Batch Mode (SIMD Analysis)
+
+Both passes iterate over contiguous ring buffer memory, enabling SIMD vectorization:
+
+| Operation | Scalar Ops | SIMD Ops (AVX-512) | Speedup |
+| :--- | :---: | :---: | :---: |
+| Pass 1: sum_y, sum_xy | $2n$ | $n/8$ | ~16× |
+| Pass 2: residuals + ssr + sst | $6n$ | $3n/4$ | ~8× |
+| Slope/intercept/bands/R² | 15 | 15 | 1× |
+
## Resources
- Carter, J. (2005). *Mastering the Trade*. McGraw-Hill.
diff --git a/lib/channels/ubands/ubands.md b/lib/channels/ubands/ubands.md
index cb4b8f81..1913a1ed 100644
--- a/lib/channels/ubands/ubands.md
+++ b/lib/channels/ubands/ubands.md
@@ -146,6 +146,37 @@ Standard deviation measures dispersion around the mean: $\sigma = \sqrt{E[(X - \
| Price at upper band | High-frequency component is large positive |
| Price at lower band | High-frequency component is large negative |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+UBANDS combines an $O(1)$ USF IIR recursion (center line) with an $O(n)$ RMS scan (band width):
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL + ADD (USF coefficients, 4 terms) | 4 | 4 | 16 |
+| ADD (USF: 2 feedback + 3 feedforward) | 5 | 1 | 5 |
+| SUB (residual = source - USF) | 1 | 1 | 1 |
+| MUL (residual² for RMS buffer) | 1 | 3 | 3 |
+| ADD (sum of squared residuals, $n$) | $n$ | 1 | $n$ |
+| DIV (sumSq / count) | 1 | 15 | 15 |
+| SQRT (RMS) | 1 | 20 | 20 |
+| MUL (k × RMS) | 1 | 3 | 3 |
+| ADD/SUB (USF ± width) | 2 | 1 | 2 |
+| **Total** | **~$n + 16$** | — | **~$n + 65$ cycles** |
+
+For period 20: ~85 cycles/bar. The USF recursion is fast ($\sim$21 cycles); the RMS window scan at $O(n)$ dominates.
+
+### Batch Mode (SIMD Analysis)
+
+The USF is recursive (IIR dependency). The RMS scan over squared residuals is vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| USF 2-pole IIR | Sequential; 5 multiply-adds per bar |
+| RMS accumulation (sum of r²) | Vectorizable with `Vector.Multiply` + horizontal sum |
+| Band arithmetic | Vectorizable in a post-pass |
+
## Resources
- Ehlers, J. F. (2024). "Ultimate Bands." *Technical Analysis of Stocks & Commodities*.
diff --git a/lib/channels/uchannel/uchannel.md b/lib/channels/uchannel/uchannel.md
index 4285a602..71b268fe 100644
--- a/lib/channels/uchannel/uchannel.md
+++ b/lib/channels/uchannel/uchannel.md
@@ -160,6 +160,38 @@ function uchannel(close[], high[], low[], strPeriod, centerPeriod, multiplier):
| Band width contracting | Volatility compression |
| Price beyond upper | Extreme positive deviation from USF trend |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+UCHANNEL runs two independent USF IIR recursions (one for close, one for True Range) plus True Range and band arithmetic — all $O(1)$:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| CMP (max(H, prevC) for TR) | 1 | 1 | 1 |
+| CMP (min(L, prevC) for TR) | 1 | 1 | 1 |
+| SUB (TH - TL for TR) | 1 | 1 | 1 |
+| MUL + ADD (USF center, 4 terms) | 4 | 4 | 16 |
+| ADD (USF center feedback, 5 terms) | 5 | 1 | 5 |
+| MUL + ADD (USF STR, 4 terms) | 4 | 4 | 16 |
+| ADD (USF STR feedback, 5 terms) | 5 | 1 | 5 |
+| MUL (k × STR) | 1 | 3 | 3 |
+| ADD/SUB (center ± width) | 2 | 1 | 2 |
+| **Total (hot)** | **24** | — | **~50 cycles** |
+
+No buffers, no window scans. All state fits in ~200 bytes (two USF 2-element histories + metadata). This is the fastest ATR-class channel indicator.
+
+### Batch Mode (SIMD Analysis)
+
+Both USF recursions are IIR-dependent, preventing SIMD parallelization across bars:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| USF IIR (2 instances) | Sequential; ~21 cycles each per bar |
+| True Range computation | Vectorizable in a batch pre-pass |
+| Band arithmetic | Vectorizable in a post-pass |
+| No allocations | Zero heap allocation; all state in registers/stack |
+
## Resources
- Ehlers, J. F. (2024). "Ultimate Channel." *Technical Analysis of Stocks & Commodities*.
diff --git a/lib/channels/vwapbands/vwapbands.md b/lib/channels/vwapbands/vwapbands.md
index dbfd191b..147b6cad 100644
--- a/lib/channels/vwapbands/vwapbands.md
+++ b/lib/channels/vwapbands/vwapbands.md
@@ -129,6 +129,38 @@ function vwapbands(source[], volume[], reset[], multiplier):
| $\sigma$ increasing | Volume-weighted dispersion growing |
| Bands expanding | Intraday volatility increasing |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+VWAPBANDS maintains three cumulative running sums plus variance computation and dual band construction — all $O(1)$:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (price × vol for sum_pv) | 1 | 3 | 3 |
+| MUL (price² × vol for sum_pv2) | 2 | 3 | 6 |
+| ADD (3 running sums) | 3 | 1 | 3 |
+| DIV (sum_pv / sum_vol for VWAP) | 1 | 15 | 15 |
+| DIV (sum_pv2 / sum_vol for E[X²]) | 1 | 15 | 15 |
+| MUL (VWAP² for variance) | 1 | 3 | 3 |
+| SUB (E[X²] - VWAP²) | 1 | 1 | 1 |
+| SQRT (σ) | 1 | 20 | 20 |
+| MUL (k × σ, 2k × σ) | 2 | 3 | 6 |
+| ADD/SUB (VWAP ± 1σ, ± 2σ, 4 bands) | 4 | 1 | 4 |
+| **Total (hot)** | **17** | — | **~76 cycles** |
+
+Session reset adds a CMP per bar. The two DIV operations and SQRT dominate. No buffers required — purely cumulative sums.
+
+### Batch Mode (SIMD Analysis)
+
+Cumulative sums are inherently sequential. Band arithmetic is vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Running sum accumulation | Sequential (prefix sum dependency) |
+| Variance → SQRT → bands | Vectorizable in a batch post-pass |
+| Session reset detection | Sequential (comparison per bar) |
+
## Resources
- Berkowitz, S., Logue, D. & Noser, E. (1988). "The Total Cost of Transactions on the NYSE." *The Journal of Finance*, 43(1), 97–112.
diff --git a/lib/channels/vwapsd/vwapsd.md b/lib/channels/vwapsd/vwapsd.md
index f3a1fbd0..2bf1c8d1 100644
--- a/lib/channels/vwapsd/vwapsd.md
+++ b/lib/channels/vwapsd/vwapsd.md
@@ -122,6 +122,38 @@ function vwapsd(source[], volume[], reset[], numDevs):
| Band width expanding | Intraday volume-weighted dispersion increasing |
| Band width near zero | Very tight price clustering around VWAP |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+VWAPSD is slightly simpler than VWAPBANDS (one band pair instead of two), with identical VWAP and variance computation:
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| MUL (price × vol for sum_pv) | 1 | 3 | 3 |
+| MUL (price² × vol for sum_pv2) | 2 | 3 | 6 |
+| ADD (3 running sums) | 3 | 1 | 3 |
+| DIV (sum_pv / sum_vol for VWAP) | 1 | 15 | 15 |
+| DIV (sum_pv2 / sum_vol for E[X²]) | 1 | 15 | 15 |
+| MUL (VWAP² for variance) | 1 | 3 | 3 |
+| SUB (E[X²] - VWAP²) | 1 | 1 | 1 |
+| SQRT (σ) | 1 | 20 | 20 |
+| MUL (k × σ) | 1 | 3 | 3 |
+| ADD/SUB (VWAP ± k·σ) | 2 | 1 | 2 |
+| **Total (hot)** | **14** | — | **~71 cycles** |
+
+Saves ~5 cycles vs VWAPBANDS by emitting 2 bands instead of 4. Session reset adds one CMP per bar.
+
+### Batch Mode (SIMD Analysis)
+
+Cumulative sums are inherently sequential. Band arithmetic is vectorizable:
+
+| Optimization | Benefit |
+| :--- | :--- |
+| Running sum accumulation | Sequential (prefix sum dependency) |
+| Variance → SQRT → bands | Vectorizable in a batch post-pass |
+| Session reset detection | Sequential (comparison per bar) |
+
## Resources
- Berkowitz, S., Logue, D. & Noser, E. (1988). "The Total Cost of Transactions on the NYSE." *The Journal of Finance*, 43(1), 97–112.
diff --git a/lib/cycles/_index.md b/lib/cycles/_index.md
index a74a224b..ab6b1faa 100644
--- a/lib/cycles/_index.md
+++ b/lib/cycles/_index.md
@@ -8,6 +8,8 @@ Cycle analysis identifies repeating patterns in price data. John Ehlers pioneere
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
+| [CCOR](ccor/Ccor.md) | Ehlers Correlation Cycle | Ehlers. Dual Pearson correlation (cos + -sin). Phasor angle + market state. |
+| [CCYC](ccyc/Ccyc.md) | Ehlers Cyber Cycle | Ehlers. 4-tap FIR + 2-pole high-pass IIR. Isolates dominant cycle component. |
| [CG](cg/Cg.md) | Ehlers Center of Gravity | Ehlers. Weighted sum position. Minimal lag cycle indicator. |
| [DSP](dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Removes trend to reveal underlying cycles. |
| [EACP](eacp/Eacp.md) | Ehlers Autocorrelation Periodogram | Ehlers. Spectral analysis via autocorrelation. Detects dominant period. |
diff --git a/lib/cycles/ccor/Ccor.Quantower.Tests.cs b/lib/cycles/ccor/Ccor.Quantower.Tests.cs
new file mode 100644
index 00000000..eb5310a2
--- /dev/null
+++ b/lib/cycles/ccor/Ccor.Quantower.Tests.cs
@@ -0,0 +1,170 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Quantower.Tests;
+
+public class CcorIndicatorTests
+{
+ [Fact]
+ public void CcorIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new CcorIndicator();
+
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(9.0, indicator.Threshold);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("CCOR - Ehlers Correlation Cycle", indicator.Name);
+ Assert.True(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void CcorIndicator_MinHistoryDepths_EqualsZero()
+ {
+ var indicator = new CcorIndicator();
+
+ Assert.Equal(0, CcorIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void CcorIndicator_ShortName_IncludesPeriodAndThreshold()
+ {
+ var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
+
+ Assert.True(indicator.ShortName.Contains("CCOR", StringComparison.Ordinal));
+ Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal));
+ Assert.True(indicator.ShortName.Contains("9.0", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CcorIndicator_Initialize_CreatesInternalCcor()
+ {
+ var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
+
+ // Initialize should not throw
+ indicator.Initialize();
+
+ // After init, line series should exist (Real + Imag + Angle + State)
+ Assert.Equal(4, indicator.LinesSeries.Count);
+ }
+
+ [Fact]
+ public void CcorIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
+ indicator.Initialize();
+
+ // Add historical data
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ // Process update
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ // All 4 line series should have a value
+ for (int s = 0; s < 4; s++)
+ {
+ Assert.Equal(1, indicator.LinesSeries[s].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[s].GetValue(0)),
+ $"Line series {s} should be finite");
+ }
+ }
+
+ [Fact]
+ public void CcorIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ for (int s = 0; s < 4; s++)
+ {
+ Assert.Equal(2, indicator.LinesSeries[s].Count);
+ }
+ }
+
+ [Fact]
+ public void CcorIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new CcorIndicator { Period = 20, Threshold = 9.0 };
+ indicator.Initialize();
+
+ // Should not throw an exception
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+
+ // Assert that the indicator still exists
+ Assert.NotNull(indicator);
+ }
+
+ [Fact]
+ public void CcorIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new CcorIndicator();
+
+ Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink));
+ Assert.Contains("Ccor.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CcorIndicator_MultipleHistoricalBars_AllFinite()
+ {
+ var indicator = new CcorIndicator { Period = 10, Threshold = 9.0 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 30; i++)
+ {
+ double price = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price + 1);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ for (int s = 0; s < 4; s++)
+ {
+ Assert.Equal(30, indicator.LinesSeries[s].Count);
+ for (int i = 0; i < 30; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[s].GetValue(i)),
+ $"Line series {s} at bar {i} should be finite");
+ }
+ }
+ }
+
+ [Fact]
+ public void CcorIndicator_CustomPeriod_ReflectedInShortName()
+ {
+ var indicator = new CcorIndicator { Period = 30, Threshold = 5.0 };
+ Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("5.0", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(SourceType.Open)]
+ [InlineData(SourceType.High)]
+ [InlineData(SourceType.Low)]
+ [InlineData(SourceType.Close)]
+ public void CcorIndicator_DifferentSources_DoNotThrow(SourceType sourceType)
+ {
+ var indicator = new CcorIndicator
+ {
+ Period = 20,
+ Threshold = 9.0,
+ Source = sourceType
+ };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ }
+}
diff --git a/lib/cycles/ccor/Ccor.Quantower.cs b/lib/cycles/ccor/Ccor.Quantower.cs
new file mode 100644
index 00000000..3f224370
--- /dev/null
+++ b/lib/cycles/ccor/Ccor.Quantower.cs
@@ -0,0 +1,80 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class CcorIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 2, 200, 1, 0)]
+ public int Period { get; set; } = 20;
+
+ [InputParameter("Threshold", sortIndex: 2, 0.1, 90.0, 0.1, 1)]
+ public double Threshold { get; set; } = 9.0;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Ccor _ccor = null!;
+ private readonly LineSeries _realSeries;
+ private readonly LineSeries _imagSeries;
+ private readonly LineSeries _angleSeries;
+ private readonly LineSeries _stateSeries;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"CCOR ({Period},{Threshold:F1})";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/ccor/Ccor.Quantower.cs";
+
+ public CcorIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = true;
+ Name = "CCOR - Ehlers Correlation Cycle";
+ Description = "Ehlers' Correlation Cycle uses dual Pearson correlation (cosine + negative sine) to derive a phasor, monotonic angle, and market state classification";
+
+ _realSeries = new LineSeries(name: "Real", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
+ _imagSeries = new LineSeries(name: "Imag", color: Color.FromArgb(128, 128, 255), width: 1, style: LineStyle.Dash);
+ _angleSeries = new LineSeries(name: "Angle", color: Color.FromArgb(200, 200, 100), width: 1, style: LineStyle.Dot);
+ _stateSeries = new LineSeries(name: "State", color: Color.FromArgb(255, 165, 0), width: 2, style: LineStyle.Histogramm);
+ AddLineSeries(_realSeries);
+ AddLineSeries(_imagSeries);
+ AddLineSeries(_angleSeries);
+ AddLineSeries(_stateSeries);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnInit()
+ {
+ _ccor = new Ccor(Period, Threshold);
+ _priceSelector = Source.GetPriceSelector();
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
+ {
+ return;
+ }
+
+ var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
+ double value = _priceSelector(item);
+ var time = this.HistoricalData.Time();
+
+ var input = new TValue(time, value);
+ TValue result = _ccor.Update(input, args.IsNewBar());
+
+ _realSeries.SetValue(result.Value, _ccor.IsHot, ShowColdValues);
+ _imagSeries.SetValue(_ccor.Imag, _ccor.IsHot, ShowColdValues);
+ _angleSeries.SetValue(_ccor.Angle, _ccor.IsHot, ShowColdValues);
+ _stateSeries.SetValue(_ccor.MarketState, _ccor.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/cycles/ccor/Ccor.Tests.cs b/lib/cycles/ccor/Ccor.Tests.cs
new file mode 100644
index 00000000..aa04b616
--- /dev/null
+++ b/lib/cycles/ccor/Ccor.Tests.cs
@@ -0,0 +1,592 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+public class CcorTests
+{
+ private static readonly GBM TestData = new(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
+
+ private static TSeries GetTestSeries(int count = 500)
+ {
+ return TestData.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ // ── A) Constructor validation ──
+
+ [Fact]
+ public void Ccor_DefaultConstructor_SetsDefaults()
+ {
+ var ind = new Ccor();
+ Assert.Equal("Ccor(20,9.0)", ind.Name);
+ Assert.Equal(20, ind.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Ccor_CustomPeriod_SetsCorrectName()
+ {
+ var ind = new Ccor(period: 30, threshold: 5.0);
+ Assert.Equal("Ccor(30,5.0)", ind.Name);
+ Assert.Equal(30, ind.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Ccor_ZeroPeriod_Throws()
+ {
+ var ex = Assert.Throws(() => new Ccor(period: 0));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_NegativePeriod_Throws()
+ {
+ var ex = Assert.Throws(() => new Ccor(period: -5));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_ZeroThreshold_Throws()
+ {
+ var ex = Assert.Throws(() => new Ccor(period: 20, threshold: 0.0));
+ Assert.Equal("threshold", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_NegativeThreshold_Throws()
+ {
+ var ex = Assert.Throws(() => new Ccor(period: 20, threshold: -1.0));
+ Assert.Equal("threshold", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_ChainConstructor_NullSource_Throws()
+ {
+ Assert.Throws(() => new Ccor(null!, 20, 9.0));
+ }
+
+ // ── B) Basic calculation ──
+
+ [Fact]
+ public void Ccor_Update_ReturnsTValue()
+ {
+ var ind = new Ccor();
+ var result = ind.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Ccor_AfterUpdate_LastIsAccessible()
+ {
+ var ind = new Ccor();
+ _ = ind.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(ind.Last.Value));
+ Assert.Equal("Ccor(20,9.0)", ind.Name);
+ }
+
+ [Fact]
+ public void Ccor_MultiOutput_AllAccessible()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(50);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ }
+
+ // All multi-output properties should be accessible and finite
+ Assert.True(double.IsFinite(ind.Real));
+ Assert.True(double.IsFinite(ind.Imag));
+ Assert.True(double.IsFinite(ind.Angle));
+ Assert.Contains(ind.MarketState, new[] { -1, 0, 1 });
+ }
+
+ [Fact]
+ public void Ccor_Real_BoundedMinusOneToOne()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(200);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ Assert.InRange(ind.Real, -1.0, 1.0);
+ }
+ }
+
+ [Fact]
+ public void Ccor_Imag_BoundedMinusOneToOne()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(200);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ Assert.InRange(ind.Imag, -1.0, 1.0);
+ }
+ }
+
+ // ── C) State + bar correction ──
+
+ [Fact]
+ public void Ccor_IsNew_True_AdvancesState()
+ {
+ var ind = new Ccor(period: 10);
+ var series = GetTestSeries(20);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv, isNew: true);
+ }
+ Assert.True(ind.IsHot);
+ }
+
+ [Fact]
+ public void Ccor_IsNew_False_DoesNotAdvance()
+ {
+ var ind = new Ccor(period: 10);
+ var series = GetTestSeries(5);
+
+ // Process 5 bars normally
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv, isNew: true);
+ }
+
+ // Rewrite last bar with same value — should produce same result each time
+ _ = ind.Update(series[^1], isNew: false);
+ double realAfterFirst = ind.Real;
+
+ _ = ind.Update(series[^1], isNew: false);
+ double realAfterSecond = ind.Real;
+
+ Assert.Equal(realAfterFirst, realAfterSecond, 10);
+ }
+
+ [Fact]
+ public void Ccor_BarCorrection_IterativeUpdatesRestore()
+ {
+ var ind = new Ccor(period: 10);
+ var series = GetTestSeries(30);
+
+ // Process first 25 bars
+ for (int i = 0; i < 25; i++)
+ {
+ _ = ind.Update(series[i]);
+ }
+
+ double realSnapshot = ind.Real;
+
+ // Apply 5 corrections (isNew=false)
+ for (int i = 0; i < 5; i++)
+ {
+ _ = ind.Update(new TValue(series[24].Time, 100.0 + i), isNew: false);
+ }
+
+ // Reapply original — should restore
+ _ = ind.Update(series[24], isNew: false);
+ Assert.Equal(realSnapshot, ind.Real, 10);
+ }
+
+ [Fact]
+ public void Ccor_Reset_ClearsState()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(50);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ }
+
+ Assert.True(ind.IsHot);
+
+ ind.Reset();
+
+ Assert.False(ind.IsHot);
+ Assert.Equal(0.0, ind.Real);
+ Assert.Equal(0.0, ind.Imag);
+ Assert.Equal(0.0, ind.Angle);
+ Assert.Equal(0, ind.MarketState);
+ Assert.Equal(default, ind.Last);
+ }
+
+ // ── D) Warmup/convergence ──
+
+ [Fact]
+ public void Ccor_IsHot_FlipsAtWarmupPeriod()
+ {
+ int period = 15;
+ var ind = new Ccor(period: period);
+ var series = GetTestSeries(period + 5);
+
+ for (int i = 0; i < period - 1; i++)
+ {
+ _ = ind.Update(series[i]);
+ Assert.False(ind.IsHot, $"Should not be hot at bar {i + 1}");
+ }
+
+ _ = ind.Update(series[period - 1]);
+ Assert.True(ind.IsHot, $"Should be hot at bar {period}");
+ }
+
+ [Fact]
+ public void Ccor_WarmupPeriod_EqualsPeriod()
+ {
+ var ind = new Ccor(period: 30);
+ Assert.Equal(30, ind.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──
+
+ [Fact]
+ public void Ccor_NaN_UsesLastValid()
+ {
+ var ind = new Ccor(period: 5);
+ var series = GetTestSeries(10);
+
+ for (int i = 0; i < 8; i++)
+ {
+ _ = ind.Update(series[i]);
+ }
+
+ _ = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(ind.Real));
+ }
+
+ [Fact]
+ public void Ccor_Infinity_UsesLastValid()
+ {
+ var ind = new Ccor(period: 5);
+ var series = GetTestSeries(10);
+
+ for (int i = 0; i < 8; i++)
+ {
+ _ = ind.Update(series[i]);
+ }
+
+ _ = ind.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(ind.Real));
+ Assert.True(double.IsFinite(ind.Imag));
+ }
+
+ [Fact]
+ public void Ccor_BatchNaN_AllFinite()
+ {
+ var series = GetTestSeries(50);
+ var ind = new Ccor(period: 10);
+
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ }
+
+ // Inject NaN batch
+ for (int i = 0; i < 5; i++)
+ {
+ _ = ind.Update(new TValue(DateTime.UtcNow, double.NaN));
+ }
+
+ Assert.True(double.IsFinite(ind.Real));
+ Assert.True(double.IsFinite(ind.Imag));
+ Assert.True(double.IsFinite(ind.Angle));
+ }
+
+ [Fact]
+ public void Ccor_EmptyTSeries_ReturnsEmpty()
+ {
+ var ind = new Ccor();
+ var result = ind.Update(new TSeries());
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Ccor_LargeDataset_NoBlowup()
+ {
+ var largeData = TestData.Fetch(10000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ var ind = new Ccor();
+
+ for (int i = 0; i < largeData.Count; i++)
+ {
+ _ = ind.Update(largeData[i]);
+ Assert.True(double.IsFinite(ind.Real), $"Non-finite Real at index {i}");
+ Assert.True(double.IsFinite(ind.Imag), $"Non-finite Imag at index {i}");
+ }
+ }
+
+ // ── F) Consistency (4 API modes match) ──
+
+ [Fact]
+ public void Ccor_FourApiModes_Match()
+ {
+ var series = GetTestSeries(100);
+ int period = 20;
+ double threshold = 9.0;
+
+ // Mode 1: Streaming
+ var ind1 = new Ccor(period, threshold);
+ var streaming = new double[series.Count];
+ for (int i = 0; i < series.Count; i++)
+ {
+ streaming[i] = ind1.Update(series[i]).Value;
+ }
+
+ // Mode 2: Batch(TSeries)
+ var batchResult = Ccor.Batch(series, period, threshold);
+
+ // Mode 3: Batch(Span)
+ double[] srcVals = new double[series.Count];
+ for (int i = 0; i < series.Count; i++)
+ {
+ srcVals[i] = series[i].Value;
+ }
+ double[] spanResult = new double[series.Count];
+ Ccor.Batch(srcVals, spanResult, period, threshold);
+
+ // Mode 4: Eventing
+ var ind4 = new Ccor(period, threshold);
+ var eventResults = new List();
+ ind4.Pub += (object? _, in TValueEventArgs e) => eventResults.Add(e.Value.Value);
+ foreach (var tv in series)
+ {
+ _ = ind4.Update(tv);
+ }
+
+ // Compare all modes
+ for (int i = 0; i < series.Count; i++)
+ {
+ Assert.Equal(streaming[i], batchResult[i].Value, 10);
+ Assert.Equal(streaming[i], spanResult[i], 10);
+ Assert.Equal(streaming[i], eventResults[i], 10);
+ }
+ }
+
+ // ── G) Span API tests ──
+
+ [Fact]
+ public void Ccor_SpanBatch_MismatchedLengths_Throws()
+ {
+ double[] src = new double[10];
+ double[] output = new double[5];
+ var ex = Assert.Throws(() => Ccor.Batch(src, output));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_SpanBatch_ZeroPeriod_Throws()
+ {
+ double[] src = new double[10];
+ double[] output = new double[10];
+ var ex = Assert.Throws(() => Ccor.Batch(src, output, period: 0));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_SpanBatch_ZeroThreshold_Throws()
+ {
+ double[] src = new double[10];
+ double[] output = new double[10];
+ var ex = Assert.Throws(() => Ccor.Batch(src, output, period: 20, threshold: 0.0));
+ Assert.Equal("threshold", ex.ParamName);
+ }
+
+ [Fact]
+ public void Ccor_SpanBatch_Empty_NoException()
+ {
+ double[] src = Array.Empty();
+ double[] output = Array.Empty();
+ Ccor.Batch(src, output); // should not throw
+ Assert.Empty(output);
+ }
+
+ [Fact]
+ public void Ccor_SpanBatch_MatchesTSeries()
+ {
+ var series = GetTestSeries(100);
+ int period = 15;
+
+ var batchResult = Ccor.Batch(series, period);
+
+ double[] srcVals = new double[series.Count];
+ for (int i = 0; i < series.Count; i++)
+ {
+ srcVals[i] = series[i].Value;
+ }
+ double[] spanResult = new double[series.Count];
+ Ccor.Batch(srcVals, spanResult, period);
+
+ for (int i = 0; i < series.Count; i++)
+ {
+ Assert.Equal(batchResult[i].Value, spanResult[i], 10);
+ }
+ }
+
+ [Fact]
+ public void Ccor_SpanBatch_NaN_Handled()
+ {
+ double[] src = { 100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109 };
+ double[] output = new double[10];
+ Ccor.Batch(src, output, period: 5);
+
+ for (int i = 0; i < output.Length; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Non-finite at index {i}");
+ }
+ }
+
+ // ── H) Chainability ──
+
+ [Fact]
+ public void Ccor_PubEvent_Fires()
+ {
+ var ind = new Ccor();
+ int count = 0;
+ ind.Pub += (object? _, in TValueEventArgs _) => count++;
+
+ var series = GetTestSeries(10);
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ }
+
+ Assert.Equal(10, count);
+ }
+
+ [Fact]
+ public void Ccor_EventChaining_Works()
+ {
+ var source = new Ccor(period: 10);
+ var chained = new Ccor(source, period: 5);
+
+ var series = GetTestSeries(50);
+ foreach (var tv in series)
+ {
+ _ = source.Update(tv);
+ }
+
+ Assert.True(chained.IsHot);
+ Assert.True(double.IsFinite(chained.Real));
+ }
+
+ // ── CCOR-specific tests ──
+
+ [Fact]
+ public void Ccor_ConstantInput_RealIsZero()
+ {
+ var ind = new Ccor(period: 10);
+ for (int i = 0; i < 30; i++)
+ {
+ _ = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
+ }
+
+ // Constant price → zero variance in x → correlation = 0
+ Assert.Equal(0.0, ind.Real, 10);
+ Assert.Equal(0.0, ind.Imag, 10);
+ }
+
+ [Fact]
+ public void Ccor_SineWave_DetectsCorrelation()
+ {
+ int period = 20;
+ var ind = new Ccor(period: period);
+
+ // Feed a perfect sine wave of the same period
+ for (int i = 0; i < 100; i++)
+ {
+ double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
+ _ = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val));
+ }
+
+ // After warmup, Real correlation with cosine should be significant (not zero)
+ double absReal = Math.Abs(ind.Real);
+ double absImag = Math.Abs(ind.Imag);
+ Assert.True(absReal > 0.1 || absImag > 0.1,
+ $"Sine wave should produce non-trivial correlation: Real={ind.Real:F4}, Imag={ind.Imag:F4}");
+ }
+
+ [Fact]
+ public void Ccor_AngleMonotonic_NeverDecreases()
+ {
+ var ind = new Ccor(period: 15);
+ var series = GetTestSeries(200);
+ double prevAngle = double.MinValue;
+
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ Assert.True(ind.Angle >= prevAngle,
+ $"Angle decreased: {ind.Angle:F4} < prev {prevAngle:F4}");
+ prevAngle = ind.Angle;
+ }
+ }
+
+ [Fact]
+ public void Ccor_MarketState_OnlyValidValues()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(200);
+
+ foreach (var tv in series)
+ {
+ _ = ind.Update(tv);
+ Assert.Contains(ind.MarketState, new[] { -1, 0, 1 });
+ }
+ }
+
+ [Fact]
+ public void Ccor_DifferentPeriods_ProduceDifferentResults()
+ {
+ var series = GetTestSeries(100);
+ var ind10 = new Ccor(period: 10);
+ var ind30 = new Ccor(period: 30);
+
+ foreach (var tv in series)
+ {
+ _ = ind10.Update(tv);
+ _ = ind30.Update(tv);
+ }
+
+ // Different periods should produce different Real values
+ Assert.NotEqual(ind10.Real, ind30.Real, 5);
+ }
+
+ [Fact]
+ public void Ccor_Prime_SetsState()
+ {
+ var ind = new Ccor(period: 10);
+ var series = GetTestSeries(20);
+ double[] vals = new double[series.Count];
+ for (int i = 0; i < series.Count; i++)
+ {
+ vals[i] = series[i].Value;
+ }
+
+ ind.Prime(vals);
+ Assert.True(ind.IsHot);
+ Assert.True(double.IsFinite(ind.Real));
+ }
+
+ [Fact]
+ public void Ccor_Calculate_ReturnsBothResultsAndIndicator()
+ {
+ var series = GetTestSeries(50);
+ var (results, indicator) = Ccor.Calculate(series);
+
+ Assert.Equal(50, results.Count);
+ Assert.True(indicator.IsHot);
+ Assert.True(double.IsFinite(indicator.Real));
+ Assert.True(double.IsFinite(indicator.Imag));
+ }
+
+ [Fact]
+ public void Ccor_Batch_TSeries_CorrectLength()
+ {
+ var series = GetTestSeries(100);
+ var result = Ccor.Batch(series);
+ Assert.Equal(100, result.Count);
+ }
+
+ [Fact]
+ public void Ccor_Update_TSeries_CorrectLength()
+ {
+ var ind = new Ccor();
+ var series = GetTestSeries(100);
+ var result = ind.Update(series);
+ Assert.Equal(100, result.Count);
+ }
+}
diff --git a/lib/cycles/ccor/Ccor.Validation.Tests.cs b/lib/cycles/ccor/Ccor.Validation.Tests.cs
new file mode 100644
index 00000000..0ca9cc84
--- /dev/null
+++ b/lib/cycles/ccor/Ccor.Validation.Tests.cs
@@ -0,0 +1,368 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+///
+/// Validation tests for CCOR - Ehlers Correlation Cycle.
+/// Since CCOR is a proprietary Ehlers algorithm with no standard library implementations
+/// (not in TA-Lib, Skender, Tulip, or Ooples), these tests validate mathematical
+/// properties of Pearson correlation and internal consistency across API modes.
+///
+public class CcorValidationTests
+{
+ private const double Tolerance = 1e-9;
+ private const long StartTime = 946_684_800_000_000_0L; // 2000-01-01 UTC in ticks
+ private static readonly TimeSpan Step = TimeSpan.FromMinutes(1);
+
+ #region Pearson Correlation Mathematical Properties
+
+ [Fact]
+ public void Ccor_ConstantInput_RealAndImagAreZero()
+ {
+ // Constant price → zero variance in x → correlation undefined → returns 0
+ var ccor = new Ccor(period: 10);
+
+ for (int i = 0; i < 100; i++)
+ {
+ ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0), true);
+ }
+
+ Assert.Equal(0.0, ccor.Real, Tolerance);
+ Assert.Equal(0.0, ccor.Imag, Tolerance);
+ }
+
+ [Fact]
+ public void Ccor_PerfectCosineInput_RealNearOne()
+ {
+ // If price exactly matches the cosine reference, Real correlation → +1
+ int period = 20;
+ var ccor = new Ccor(period: period);
+
+ double twoPiOverN = 2.0 * Math.PI / period;
+ for (int i = 0; i < 200; i++)
+ {
+ double val = Math.Cos(twoPiOverN * (i % period));
+ ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
+ }
+
+ // After many full cycles, Real should be very close to +1
+ Assert.True(ccor.Real > 0.95,
+ $"Perfect cosine input should give Real ≈ 1.0, got {ccor.Real:F6}");
+ }
+
+ [Fact]
+ public void Ccor_PerfectNegSineInput_ImagHighMagnitude()
+ {
+ // If price has -sin periodicity, Imag correlation magnitude should be near 1.0
+ int period = 20;
+ var ccor = new Ccor(period: period);
+
+ double twoPiOverN = 2.0 * Math.PI / period;
+ for (int i = 0; i < 200; i++)
+ {
+ double val = -Math.Sin(twoPiOverN * (i % period));
+ ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
+ }
+
+ Assert.True(Math.Abs(ccor.Imag) > 0.90,
+ $"Perfect -sin input should give |Imag| ≈ 1.0, got {ccor.Imag:F6}");
+ }
+
+ [Fact]
+ public void Ccor_RealAndImag_BoundedMinusOneToOne()
+ {
+ // Pearson correlation coefficient is always in [-1, +1]
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(1000, StartTime, Step);
+ var ccor = new Ccor(period: 20);
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
+ Assert.InRange(ccor.Real, -1.0, 1.0);
+ Assert.InRange(ccor.Imag, -1.0, 1.0);
+ }
+ }
+
+ [Fact]
+ public void Ccor_SineWave_RealAndImagAreOrthogonal()
+ {
+ // For a pure sine wave at the indicator's period, the Real (cosine) and Imag (-sine)
+ // correlations should be approximately orthogonal components of a phasor
+ int period = 20;
+ var ccor = new Ccor(period: period);
+
+ for (int i = 0; i < 200; i++)
+ {
+ double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
+ ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
+ }
+
+ // Both should be non-trivial
+ Assert.True(Math.Abs(ccor.Real) > 0.01 || Math.Abs(ccor.Imag) > 0.01,
+ $"Sine wave should produce non-trivial phasor: Real={ccor.Real:F4}, Imag={ccor.Imag:F4}");
+
+ // R² + I² should be near 1 for a pure tone at the matched frequency
+ double magnitude = Math.Sqrt(ccor.Real * ccor.Real + ccor.Imag * ccor.Imag);
+ Assert.True(magnitude > 0.5,
+ $"Phasor magnitude should be significant for matched sine: {magnitude:F4}");
+ }
+
+ #endregion
+
+ #region Angle Properties
+
+ [Fact]
+ public void Ccor_Angle_MonotonicallyNonDecreasing()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var ccor = new Ccor(period: 20);
+ double prevAngle = double.MinValue;
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
+ Assert.True(ccor.Angle >= prevAngle,
+ $"Angle decreased at bar {i}: {ccor.Angle:F4} < prev {prevAngle:F4}");
+ prevAngle = ccor.Angle;
+ }
+ }
+
+ [Fact]
+ public void Ccor_Angle_AdvancesOnCyclicInput()
+ {
+ // For cyclic input, the angle should advance significantly
+ int period = 20;
+ var ccor = new Ccor(period: period);
+
+ for (int i = 0; i < 200; i++)
+ {
+ double val = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
+ ccor.Update(new TValue(DateTime.UtcNow.AddMinutes(i), val), true);
+ }
+
+ Assert.True(ccor.Angle > 0.0,
+ $"Angle should advance on cyclic input, got {ccor.Angle:F4}");
+ }
+
+ #endregion
+
+ #region Market State Properties
+
+ [Fact]
+ public void Ccor_MarketState_OnlyValidValues()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var ccor = new Ccor(period: 20, threshold: 9.0);
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
+ Assert.Contains(ccor.MarketState, new[] { -1, 0, 1 });
+ }
+ }
+
+ [Fact]
+ public void Ccor_MarketState_HasVariation()
+ {
+ // Over enough data, all three states should appear at least once
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(2000, StartTime, Step);
+ var ccor = new Ccor(period: 20, threshold: 9.0);
+ var states = new HashSet();
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ ccor.Update(new TValue(bars[i].Time, bars[i].Close), true);
+ states.Add(ccor.MarketState);
+ }
+
+ Assert.True(states.Count >= 2,
+ $"Expected at least 2 distinct market states, got {states.Count}: {string.Join(",", states)}");
+ }
+
+ #endregion
+
+ #region Deterministic Reproducibility
+
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(456)]
+ public void Ccor_DeterministicOutput(int seed)
+ {
+ var gbm1 = new GBM(seed: seed);
+ var bars1 = gbm1.Fetch(200, StartTime, Step);
+
+ var gbm2 = new GBM(seed: seed);
+ var bars2 = gbm2.Fetch(200, StartTime, Step);
+
+ var ccor1 = new Ccor(period: 20, threshold: 9.0);
+ var ccor2 = new Ccor(period: 20, threshold: 9.0);
+
+ for (int i = 0; i < bars1.Count; i++)
+ {
+ var r1 = ccor1.Update(new TValue(bars1[i].Time, bars1[i].Close));
+ var r2 = ccor2.Update(new TValue(bars2[i].Time, bars2[i].Close));
+
+ Assert.Equal(r1.Value, r2.Value, Tolerance);
+ }
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(20)]
+ [InlineData(50)]
+ public void Ccor_AllPeriods_ProduceFiniteOutput(int period)
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var ccor = new Ccor(period: period);
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ var r = ccor.Update(new TValue(bars[i].Time, bars[i].Close));
+ Assert.True(double.IsFinite(r.Value), $"Non-finite at bar {i} with period={period}");
+ Assert.True(double.IsFinite(ccor.Real), $"Non-finite Real at bar {i}");
+ Assert.True(double.IsFinite(ccor.Imag), $"Non-finite Imag at bar {i}");
+ Assert.True(double.IsFinite(ccor.Angle), $"Non-finite Angle at bar {i}");
+ }
+ }
+
+ #endregion
+
+ #region Consistency Validation
+
+ [Fact]
+ public void Ccor_BatchMatchesStreaming_OnGBM()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var source = bars.Close;
+
+ // Streaming
+ var ccorStream = new Ccor(period: 20, threshold: 9.0);
+ var streamResults = new double[source.Count];
+ for (int i = 0; i < source.Count; i++)
+ {
+ var r = ccorStream.Update(source[i], true);
+ streamResults[i] = r.Value;
+ }
+
+ // Batch
+ var batchResults = Ccor.Batch(source, 20, 9.0);
+
+ for (int i = 0; i < source.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, Tolerance);
+ }
+ }
+
+ [Fact]
+ public void Ccor_SpanMatchesBatch_OnGBM()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var source = bars.Close;
+
+ // TSeries batch
+ var batchResults = Ccor.Batch(source, 20, 9.0);
+
+ // Span batch
+ double[] values = new double[source.Count];
+ for (int i = 0; i < source.Count; i++)
+ {
+ values[i] = source[i].Value;
+ }
+
+ double[] output = new double[values.Length];
+ Ccor.Batch(values.AsSpan(), output.AsSpan(), 20, 9.0);
+
+ for (int i = 0; i < source.Count; i++)
+ {
+ Assert.Equal(batchResults[i].Value, output[i], Tolerance);
+ }
+ }
+
+ [Fact]
+ public void Ccor_ResetAndReprocess_Matches()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(200, StartTime, Step);
+ var source = bars.Close;
+
+ var ccor = new Ccor(period: 20, threshold: 9.0);
+ var results1 = ccor.Update(source);
+
+ ccor.Reset();
+ var results2 = ccor.Update(source);
+
+ Assert.Equal(results1.Count, results2.Count);
+ for (int i = 0; i < results1.Count; i++)
+ {
+ Assert.Equal(results1[i].Value, results2[i].Value, Tolerance);
+ }
+ }
+
+ #endregion
+
+ #region Period Sensitivity
+
+ [Fact]
+ public void Ccor_DifferentPeriods_ProduceDifferentResults()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(200, StartTime, Step);
+
+ var ccor10 = new Ccor(period: 10);
+ var ccor30 = new Ccor(period: 30);
+ double diffEnergy = 0;
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ var tv = new TValue(bars[i].Time, bars[i].Close);
+ var r10 = ccor10.Update(tv);
+ var r30 = ccor30.Update(tv);
+
+ if (i > 30)
+ {
+ double d = r10.Value - r30.Value;
+ diffEnergy += d * d;
+ }
+ }
+
+ Assert.True(diffEnergy > 1e-6,
+ $"Different periods should produce different outputs, diffEnergy={diffEnergy}");
+ }
+
+ [Fact]
+ public void Ccor_DifferentThresholds_ProduceDifferentMarketStates()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+
+ var ccorTight = new Ccor(period: 20, threshold: 1.0);
+ var ccorLoose = new Ccor(period: 20, threshold: 50.0);
+
+ int statesDiffer = 0;
+ for (int i = 0; i < bars.Count; i++)
+ {
+ var tv = new TValue(bars[i].Time, bars[i].Close);
+ ccorTight.Update(tv);
+ ccorLoose.Update(tv);
+
+ if (ccorTight.MarketState != ccorLoose.MarketState)
+ {
+ statesDiffer++;
+ }
+ }
+
+ // Real/Imag/Angle are independent of threshold — only MarketState differs
+ Assert.True(statesDiffer > 0,
+ "Different thresholds should produce different market state classifications");
+ }
+
+ #endregion
+}
diff --git a/lib/cycles/ccor/Ccor.cs b/lib/cycles/ccor/Ccor.cs
new file mode 100644
index 00000000..d992dffc
--- /dev/null
+++ b/lib/cycles/ccor/Ccor.cs
@@ -0,0 +1,435 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// CCOR: Ehlers Correlation Cycle — extracts cycle phase by computing Pearson correlation
+/// of a price window against cosine (Real) and negative-sine (Imaginary) reference waves,
+/// converting the resulting phasor to an angle with monotonic constraint and classifying
+/// the market state as trending or cycling.
+///
+///
+/// From John F. Ehlers, "Correlation As A Cycle Indicator" (Stocks & Commodities, June 2020).
+///
+/// Algorithm:
+/// 1. Dual Pearson correlation over sliding window of N bars:
+/// Real = corr(price, cos(2πk/N)), Imag = corr(price, -sin(2πk/N))
+/// 2. Phasor angle = 90° + atan(Real/Imag) with quadrant fix (if Imag > 0: angle -= 180°)
+/// 3. Monotonic constraint: angle = max(angle, prev_angle) — prevents backward spin
+/// 4. State detection: |Δangle| < threshold → trending (+1 uptrend / -1 downtrend), else cycling (0)
+///
+/// Properties:
+/// - O(period) per bar for dual correlation loops
+/// - Precomputed cos/sin tables eliminate per-bar trig calls
+/// - Real, Imag bounded [-1, +1] by Pearson construction
+/// - Zero allocation in hot path (RingBuffer is pre-allocated)
+///
+[SkipLocalsInit]
+public sealed class Ccor : AbstractBase
+{
+ private readonly int _period;
+ private readonly double _threshold;
+ private readonly double[] _cosTable;
+ private readonly double[] _negSinTable;
+ private readonly RingBuffer _buf;
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(double PrevAngle, int Count, double LastValid);
+
+ private State _s;
+ private State _ps;
+
+ /// Pearson correlation of price with cosine reference wave. Range [-1, +1].
+ public double Real { get; private set; }
+
+ /// Pearson correlation of price with negative-sine reference wave. Range [-1, +1].
+ public double Imag { get; private set; }
+
+ /// Phasor angle (degrees), monotonically increasing.
+ public double Angle { get; private set; }
+
+ /// Market state: +1 = uptrend, -1 = downtrend, 0 = cycling.
+ public int MarketState { get; private set; }
+
+ ///
+ public override bool IsHot => _s.Count >= WarmupPeriod;
+
+ ///
+ /// Creates a new Ccor indicator.
+ ///
+ /// Presumed dominant cycle wavelength. Must be > 0. Default 20.
+ /// Angle rate threshold (degrees) for state detection. Must be > 0. Default 9.0.
+ public Ccor(int period = 20, double threshold = 9.0)
+ {
+ if (period <= 0)
+ {
+ throw new ArgumentException("Period must be greater than 0.", nameof(period));
+ }
+ if (threshold <= 0.0)
+ {
+ throw new ArgumentException("Threshold must be greater than 0.", nameof(threshold));
+ }
+
+ _period = period;
+ _threshold = threshold;
+
+ // Precompute cos/sin lookup tables
+ _cosTable = new double[period];
+ _negSinTable = new double[period];
+ double twoPiOverN = 2.0 * Math.PI / period;
+
+ for (int k = 0; k < period; k++)
+ {
+ double angle = twoPiOverN * k;
+ _cosTable[k] = Math.Cos(angle);
+ _negSinTable[k] = -Math.Sin(angle);
+ }
+
+ _buf = new(period);
+ Name = $"Ccor({period},{threshold:F1})";
+ WarmupPeriod = period;
+ _s = default;
+ _ps = default;
+ }
+
+ ///
+ /// Creates a new Ccor indicator chained to a publisher source.
+ ///
+ public Ccor(ITValuePublisher source, int period = 20, double threshold = 9.0) : this(period, threshold)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ source.Pub += HandleInput;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void HandleInput(object? sender, in TValueEventArgs e)
+ {
+ Update(e.Value, e.IsNew);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ // State management: save/restore for bar correction
+ if (isNew)
+ {
+ _ps = _s;
+ _buf.Snapshot();
+ }
+ else
+ {
+ _s = _ps;
+ _buf.Restore();
+ }
+
+ var s = _s;
+
+ double price = input.Value;
+
+ // NaN/Infinity guard: substitute last valid value
+ if (!double.IsFinite(price))
+ {
+ price = s.LastValid;
+ }
+ else
+ {
+ s = s with { LastValid = price };
+ }
+
+ // Increment bar count
+ int count = isNew ? s.Count + 1 : s.Count;
+
+ // Add price to ring buffer
+ _buf.Add(price);
+
+ // Compute dual Pearson correlations
+ int n = Math.Min(count, _period);
+ double realVal = 0, imagVal = 0;
+ double angleVal = 0;
+ int stateVal = 0;
+
+ if (n >= 2)
+ {
+ realVal = ComputeCorrelation(_buf, _cosTable, n);
+ imagVal = ComputeCorrelation(_buf, _negSinTable, n);
+
+ // Phasor angle (degrees) with quadrant resolution
+ if (imagVal != 0.0)
+ {
+ angleVal = 90.0 + Math.Atan(realVal / imagVal) * (180.0 / Math.PI);
+ }
+ if (imagVal > 0.0)
+ {
+ angleVal -= 180.0;
+ }
+
+ // Monotonic constraint: angle cannot decrease
+ double savedPrev = s.PrevAngle;
+ if (angleVal < savedPrev)
+ {
+ angleVal = savedPrev;
+ }
+
+ // Market state detection
+ double angleChange = Math.Abs(angleVal - savedPrev);
+ if (angleChange < _threshold && angleVal >= 0.0)
+ {
+ stateVal = 1; // uptrend
+ }
+ else if (angleChange < _threshold && angleVal <= 0.0)
+ {
+ stateVal = -1; // downtrend
+ }
+ // else stateVal = 0 (cycling)
+ }
+
+ Real = realVal;
+ Imag = imagVal;
+ Angle = angleVal;
+ MarketState = stateVal;
+
+ _s = new State(angleVal, count, s.LastValid);
+
+ Last = new TValue(input.Time, realVal);
+ PubEvent(Last, isNew);
+ return Last;
+ }
+
+ ///
+ /// Processes a full TSeries, returning the Real correlation for each bar.
+ ///
+ 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);
+
+ for (int i = 0; i < len; i++)
+ {
+ var result = Update(source[i]);
+ vSpan[i] = result.Value;
+ }
+ source.Times.CopyTo(tSpan);
+
+ return new TSeries(t, v);
+ }
+
+ ///
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (double value in source)
+ {
+ Update(new TValue(DateTime.UtcNow, value));
+ }
+ }
+
+ ///
+ /// Static batch: creates a Ccor, processes source, returns output TSeries.
+ ///
+ public static TSeries Batch(TSeries source, int period = 20, double threshold = 9.0)
+ {
+ var ind = new Ccor(period, threshold);
+ return ind.Update(source);
+ }
+
+ ///
+ /// Static span-based batch: computes correlation cycle Real component into output span.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 20, double threshold = 9.0)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length.", nameof(output));
+ }
+ if (period <= 0)
+ {
+ throw new ArgumentException("Period must be greater than 0.", nameof(period));
+ }
+ if (threshold <= 0.0)
+ {
+ throw new ArgumentException("Threshold must be greater than 0.", nameof(threshold));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ // Precompute trig tables
+ const int StackallocThreshold = 256;
+ double[]? rentedCos = null;
+ scoped Span cosTab;
+
+ if (period <= StackallocThreshold)
+ {
+ cosTab = stackalloc double[period];
+ }
+ else
+ {
+ rentedCos = ArrayPool.Shared.Rent(period);
+ cosTab = rentedCos.AsSpan(0, period);
+ }
+
+ try
+ {
+ double twoPiOverN = 2.0 * Math.PI / period;
+ for (int k = 0; k < period; k++)
+ {
+ cosTab[k] = Math.Cos(twoPiOverN * k);
+ }
+
+ // Price ring buffer (manual circular)
+ double[]? rentedBuf = null;
+ scoped Span priceBuf;
+ if (period <= StackallocThreshold)
+ {
+ priceBuf = stackalloc double[period];
+ }
+ else
+ {
+ rentedBuf = ArrayPool.Shared.Rent(period);
+ priceBuf = rentedBuf.AsSpan(0, period);
+ }
+
+ try
+ {
+ priceBuf.Clear();
+ int bufIdx = 0;
+ int filled = 0;
+ double lastValid = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (!double.IsFinite(val))
+ {
+ val = lastValid;
+ }
+ else
+ {
+ lastValid = val;
+ }
+
+ priceBuf[bufIdx] = val;
+ bufIdx = (bufIdx + 1) % period;
+ if (filled < period)
+ {
+ filled++;
+ }
+
+ int n = filled;
+ double realVal = 0;
+
+ if (n >= 2)
+ {
+ // Compute Real correlation (cosine)
+ double sx = 0, sxx = 0, sxy = 0;
+ double sy = 0, syy = 0;
+
+ for (int k = 0; k < n; k++)
+ {
+ int idx = ((bufIdx - 1 - k) % period + period) % period;
+ double x = priceBuf[idx];
+ double y = cosTab[k];
+ sx += x;
+ sxx += x * x;
+ sxy += x * y;
+ sy += y;
+ syy += y * y;
+ }
+
+ double nd = n;
+ double dp = (nd * sxx - sx * sx) * (nd * syy - sy * sy);
+ realVal = dp > 0.0 ? Math.Clamp((nd * sxy - sx * sy) / Math.Sqrt(dp), -1.0, 1.0) : 0.0;
+ }
+
+ output[i] = realVal;
+ }
+ }
+ finally
+ {
+ if (rentedBuf != null)
+ {
+ ArrayPool.Shared.Return(rentedBuf);
+ }
+ }
+ }
+ finally
+ {
+ if (rentedCos != null)
+ {
+ ArrayPool.Shared.Return(rentedCos);
+ }
+ }
+ }
+
+ ///
+ /// Static convenience method: returns (TSeries results, Ccor indicator) for inspection.
+ ///
+ public static (TSeries Results, Ccor Indicator) Calculate(TSeries source, int period = 20, double threshold = 9.0)
+ {
+ var ind = new Ccor(period, threshold);
+ var results = ind.Update(source);
+ return (results, ind);
+ }
+
+ ///
+ public override void Reset()
+ {
+ _s = default;
+ _ps = default;
+ _buf.Clear();
+ Last = default;
+ Real = 0;
+ Imag = 0;
+ Angle = 0;
+ MarketState = 0;
+ }
+
+ ///
+ /// Computes Pearson correlation between the most recent n values in RingBuffer
+ /// and the first n entries of a reference wave table.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double ComputeCorrelation(RingBuffer buf, double[] refTable, int n)
+ {
+ double sx = 0, sxx = 0, sxy = 0;
+ double sy = 0, syy = 0;
+ int newest = buf.Count - 1;
+
+ for (int k = 0; k < n; k++)
+ {
+ double x = buf[newest - k];
+ double y = refTable[k];
+ sx += x;
+ sxx += x * x;
+ sxy += x * y;
+ sy += y;
+ syy += y * y;
+ }
+
+ double nd = n;
+ double denomProd = (nd * sxx - sx * sx) * (nd * syy - sy * sy);
+ if (denomProd <= 0.0)
+ {
+ return 0.0;
+ }
+
+ double r = (nd * sxy - sx * sy) / Math.Sqrt(denomProd);
+ return Math.Clamp(r, -1.0, 1.0);
+ }
+}
diff --git a/lib/cycles/ccor/Ccor.md b/lib/cycles/ccor/Ccor.md
index cbc74b86..dd1c08e6 100644
--- a/lib/cycles/ccor/Ccor.md
+++ b/lib/cycles/ccor/Ccor.md
@@ -129,6 +129,32 @@ function CCOR(source, period, threshold):
| `angle` | monotonically increasing degrees | Phasor angle of detected cycle |
| `state` | $\{-1, 0, +1\}$ | $-1$ = downtrend, $0$ = cycling, $+1$ = uptrend |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD/SUB | 5×N | 1 | 5N |
+| MUL | 5×N | 3 | 15N |
+| DIV | 2 | 15 | 30 |
+| SQRT | 1 | 15 | 15 |
+| ATAN | 1 | 20 | 20 |
+| CMP | 3 | 1 | 3 |
+| CLAMP | 1 | 1 | 1 |
+| **Total** | **~10N+8** | — | **~20N+69** |
+
+For default period $N = 20$: ~269 cycles per bar. The O(N) cost comes from dual Pearson correlation loops over the sliding window. Precomputed cos/sin tables eliminate per-bar trig calls.
+
+### Quality Metrics
+
+| Metric | Score | Notes |
+| :--- | :---: | :--- |
+| **Accuracy** | 9/10 | Pearson correlation bounded [-1, +1] by construction |
+| **Timeliness** | 8/10 | Full-window correlation; no recursive lag |
+| **Smoothness** | 7/10 | Monotonic angle constraint prevents backward jumps |
+| **Memory** | 8/10 | O(N) ring buffer + precomputed trig tables |
+
## Resources
- **Ehlers, J.F.** "Correlation As A Cycle Indicator." *Technical Analysis of Stocks & Commodities*, June 2020.
diff --git a/lib/cycles/ccyc/Ccyc.Quantower.Tests.cs b/lib/cycles/ccyc/Ccyc.Quantower.Tests.cs
new file mode 100644
index 00000000..cbf12c82
--- /dev/null
+++ b/lib/cycles/ccyc/Ccyc.Quantower.Tests.cs
@@ -0,0 +1,161 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Quantower.Tests;
+
+public class CcycIndicatorTests
+{
+ [Fact]
+ public void CcycIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new CcycIndicator();
+
+ Assert.Equal(0.07, indicator.Alpha);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("CCYC - Ehlers Cyber Cycle", indicator.Name);
+ Assert.True(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void CcycIndicator_MinHistoryDepths_EqualsZero()
+ {
+ var indicator = new CcycIndicator();
+
+ Assert.Equal(0, CcycIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void CcycIndicator_ShortName_IncludesAlpha()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+
+ Assert.True(indicator.ShortName.Contains("CCYC", StringComparison.Ordinal));
+ Assert.True(indicator.ShortName.Contains("0.07", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CcycIndicator_Initialize_CreatesInternalCcyc()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+
+ // Initialize should not throw
+ indicator.Initialize();
+
+ // After init, line series should exist (Cycle + Trigger)
+ Assert.Equal(2, indicator.LinesSeries.Count);
+ }
+
+ [Fact]
+ public void CcycIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+ indicator.Initialize();
+
+ // Add historical data
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ // Process update
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ // Line series should have a value
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ Assert.Equal(1, indicator.LinesSeries[1].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)));
+ }
+
+ [Fact]
+ public void CcycIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ Assert.Equal(2, indicator.LinesSeries[1].Count);
+ }
+
+ [Fact]
+ public void CcycIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+ indicator.Initialize();
+
+ // Should not throw an exception
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+
+ // Assert that the indicator still exists
+ Assert.NotNull(indicator);
+ }
+
+ [Fact]
+ public void CcycIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new CcycIndicator();
+
+ Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink));
+ Assert.Contains("Ccyc.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CcycIndicator_MultipleHistoricalBars_AllFinite()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.07 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 20; i++)
+ {
+ double price = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price + 1);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ Assert.Equal(20, indicator.LinesSeries[0].Count);
+ Assert.Equal(20, indicator.LinesSeries[1].Count);
+
+ for (int i = 0; i < 20; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
+ Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(i)));
+ }
+ }
+
+ [Fact]
+ public void CcycIndicator_CustomAlpha_ReflectedInShortName()
+ {
+ var indicator = new CcycIndicator { Alpha = 0.15 };
+ Assert.Contains("0.15", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(SourceType.Open)]
+ [InlineData(SourceType.High)]
+ [InlineData(SourceType.Low)]
+ [InlineData(SourceType.Close)]
+ public void CcycIndicator_DifferentSources_DoNotThrow(SourceType sourceType)
+ {
+ var indicator = new CcycIndicator
+ {
+ Alpha = 0.07,
+ Source = sourceType
+ };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ }
+}
diff --git a/lib/cycles/ccyc/Ccyc.Quantower.cs b/lib/cycles/ccyc/Ccyc.Quantower.cs
new file mode 100644
index 00000000..ed42a709
--- /dev/null
+++ b/lib/cycles/ccyc/Ccyc.Quantower.cs
@@ -0,0 +1,69 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class CcycIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Alpha", sortIndex: 1, 0.01, 0.99, 0.01, 2)]
+ public double Alpha { get; set; } = 0.07;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Ccyc _ccyc = null!;
+ private readonly LineSeries _cycleSeries;
+ private readonly LineSeries _triggerSeries;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"CCYC ({Alpha:F2})";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/ccyc/Ccyc.Quantower.cs";
+
+ public CcycIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = true;
+ Name = "CCYC - Ehlers Cyber Cycle";
+ Description = "Ehlers' Cyber Cycle isolates the dominant cycle component using a 4-tap FIR pre-smoother and a 2-pole high-pass IIR filter";
+
+ _cycleSeries = new LineSeries(name: "Cycle", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
+ _triggerSeries = new LineSeries(name: "Trigger", color: Color.FromArgb(128, 128, 255), width: 1, style: LineStyle.Dash);
+ AddLineSeries(_cycleSeries);
+ AddLineSeries(_triggerSeries);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnInit()
+ {
+ _ccyc = new Ccyc(Alpha);
+ _priceSelector = Source.GetPriceSelector();
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
+ {
+ return;
+ }
+
+ var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
+ double value = _priceSelector(item);
+ var time = this.HistoricalData.Time();
+
+ var input = new TValue(time, value);
+ TValue result = _ccyc.Update(input, args.IsNewBar());
+
+ _cycleSeries.SetValue(result.Value, _ccyc.IsHot, ShowColdValues);
+ _triggerSeries.SetValue(_ccyc.Trigger, _ccyc.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/cycles/ccyc/Ccyc.Tests.cs b/lib/cycles/ccyc/Ccyc.Tests.cs
new file mode 100644
index 00000000..6314442f
--- /dev/null
+++ b/lib/cycles/ccyc/Ccyc.Tests.cs
@@ -0,0 +1,517 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+public class CcycTests
+{
+ private const long StartTime = 946_684_800_000_000_0L; // 2000-01-01 UTC in ticks
+ private static readonly TimeSpan Step = TimeSpan.FromMinutes(1);
+ private static readonly GBM TestData = new(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
+
+ private static TSeries GetTestSeries(int count = 500)
+ {
+ return TestData.Fetch(count, StartTime, Step).Close;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // A) Constructor Defaults
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_DefaultAlpha_NoThrow()
+ {
+ var ccyc = new Ccyc();
+ Assert.NotNull(ccyc);
+ Assert.Equal(7, ccyc.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Ccyc_CustomAlpha_NoThrow()
+ {
+ var ccyc = new Ccyc(alpha: 0.15);
+ Assert.NotNull(ccyc);
+ }
+
+ [Fact]
+ public void Ccyc_AlphaZero_Throws()
+ {
+ Assert.Throws(() => new Ccyc(alpha: 0.0));
+ }
+
+ [Fact]
+ public void Ccyc_AlphaOne_Throws()
+ {
+ Assert.Throws(() => new Ccyc(alpha: 1.0));
+ }
+
+ [Fact]
+ public void Ccyc_AlphaNegative_Throws()
+ {
+ Assert.Throws(() => new Ccyc(alpha: -0.1));
+ }
+
+ [Fact]
+ public void Ccyc_AlphaAboveOne_Throws()
+ {
+ Assert.Throws(() => new Ccyc(alpha: 1.5));
+ }
+
+ [Fact]
+ public void Ccyc_Name_ContainsAlpha()
+ {
+ var ccyc = new Ccyc(0.07);
+ Assert.Contains("0.07", ccyc.Name, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Ccyc_WarmupPeriod_IsSeven()
+ {
+ var ccyc = new Ccyc();
+ Assert.Equal(7, ccyc.WarmupPeriod);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // B) Basic Calculation
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_SingleValue_ReturnsFinite()
+ {
+ var ccyc = new Ccyc();
+ var result = ccyc.Update(new TValue(DateTime.UtcNow, 100));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Ccyc_MultipleValues_AllFinite()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries();
+ var results = ccyc.Update(source);
+ for (int i = 0; i < results.Count; i++)
+ {
+ Assert.True(double.IsFinite(results[i].Value), $"Non-finite at index {i}");
+ }
+ }
+
+ [Fact]
+ public void Ccyc_OutputNotZeroWhenHot()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries(200);
+ var results = ccyc.Update(source);
+
+ // After warmup, at least some values should be non-zero
+ bool anyNonZero = false;
+ for (int i = ccyc.WarmupPeriod; i < results.Count; i++)
+ {
+ if (Math.Abs(results[i].Value) > 1e-10)
+ {
+ anyNonZero = true;
+ break;
+ }
+ }
+ Assert.True(anyNonZero, "All post-warmup values are zero");
+ }
+
+ [Fact]
+ public void Ccyc_IsOscillator_ChangesSigns()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries(200);
+ var results = ccyc.Update(source);
+
+ bool hasPositive = false;
+ bool hasNegative = false;
+ for (int i = ccyc.WarmupPeriod; i < results.Count; i++)
+ {
+ if (results[i].Value > 0)
+ {
+ hasPositive = true;
+ }
+
+ if (results[i].Value < 0)
+ {
+ hasNegative = true;
+ }
+
+ if (hasPositive && hasNegative)
+ {
+ break;
+ }
+ }
+ Assert.True(hasPositive && hasNegative, "Cycle should oscillate around zero");
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // C) State Management / Bar Correction
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_BarCorrection_RestoresState()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries(50);
+ for (int i = 0; i < source.Count; i++)
+ {
+ ccyc.Update(source[i], true);
+ }
+
+ // Get state after all bars
+ double lastVal = ccyc.Last.Value;
+
+ // Simulate bar correction: update with isNew=false
+ var correctedTv = new TValue(DateTime.UtcNow, 999.0);
+ ccyc.Update(correctedTv, false);
+ _ = ccyc.Last.Value;
+
+ // Now redo with original last value using isNew=false
+ ccyc.Update(source[^1], false);
+ double restoredVal = ccyc.Last.Value;
+
+ Assert.Equal(lastVal, restoredVal, 10);
+ }
+
+ [Fact]
+ public void Ccyc_Reset_ClearsState()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries(100);
+ ccyc.Update(source);
+
+ // Verify hot
+ Assert.True(ccyc.IsHot);
+
+ ccyc.Reset();
+
+ // After reset, should not be hot
+ Assert.False(ccyc.IsHot);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // D) Warmup
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_NotHot_BeforeWarmup()
+ {
+ var ccyc = new Ccyc();
+ for (int i = 0; i < 6; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i), true);
+ Assert.False(ccyc.IsHot, $"Should not be hot at bar {i + 1}");
+ }
+ }
+
+ [Fact]
+ public void Ccyc_IsHot_AtWarmup()
+ {
+ var ccyc = new Ccyc();
+ for (int i = 0; i < 7; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i), true);
+ }
+ Assert.True(ccyc.IsHot);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // E) Robustness
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_NaN_HandledGracefully()
+ {
+ var ccyc = new Ccyc();
+ for (int i = 0; i < 10; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i), true);
+ }
+ _ = ccyc.Last.Value;
+
+ // Feed NaN
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(10), double.NaN), true);
+ Assert.True(double.IsFinite(ccyc.Last.Value));
+ }
+
+ [Fact]
+ public void Ccyc_Infinity_HandledGracefully()
+ {
+ var ccyc = new Ccyc();
+ for (int i = 0; i < 10; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i), true);
+ }
+
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(10), double.PositiveInfinity), true);
+ Assert.True(double.IsFinite(ccyc.Last.Value));
+ }
+
+ [Fact]
+ public void Ccyc_EmptyTSeries_ReturnsEmpty()
+ {
+ var ccyc = new Ccyc();
+ _ = ccyc.Update(new TSeries());
+ Assert.True(true); // No throw
+ }
+
+ [Fact]
+ public void Ccyc_LargeDataset_NoBlowup()
+ {
+ var ccyc = new Ccyc();
+ var source = TestData.Fetch(10000, StartTime, Step).Close;
+ var results = ccyc.Update(source);
+ for (int i = 0; i < results.Count; i++)
+ {
+ Assert.True(double.IsFinite(results[i].Value), $"Non-finite at {i}");
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // F) Consistency (4-API-mode)
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_StreamingMatchesBatch()
+ {
+ var source = GetTestSeries(200);
+
+ // Streaming
+ var ccycStreaming = new Ccyc();
+ for (int i = 0; i < source.Count; i++)
+ {
+ ccycStreaming.Update(source[i], true);
+ }
+
+ // Batch
+ var batchResults = Ccyc.Batch(source);
+
+ Assert.Equal(source.Count, batchResults.Count);
+
+ // Compare last 50 values
+ for (int i = source.Count - 50; i < source.Count; i++)
+ {
+ // Streaming processes all bars and streaming result is the last one
+ // But for exact comparison, batch results should match streaming approach
+ }
+
+ // The batch method creates a fresh indicator and calls Update(TSeries),
+ // which processes sequentially — should match streaming exactly
+ var ccyc2 = new Ccyc();
+ var results2 = ccyc2.Update(source);
+ Assert.Equal(batchResults.Count, results2.Count);
+ for (int i = 0; i < batchResults.Count; i++)
+ {
+ Assert.Equal(batchResults[i].Value, results2[i].Value, 10);
+ }
+ }
+
+ [Fact]
+ public void Ccyc_SpanBatchMatchesTSeriesBatch()
+ {
+ var source = GetTestSeries(200);
+ var batchResults = Ccyc.Batch(source);
+
+ // Span batch
+ double[] values = new double[source.Count];
+ for (int i = 0; i < source.Count; i++)
+ {
+ values[i] = source[i].Value;
+ }
+
+ double[] output = new double[values.Length];
+ Ccyc.Batch(values.AsSpan(), output.AsSpan());
+
+ // Compare
+ for (int i = 0; i < batchResults.Count; i++)
+ {
+ Assert.Equal(batchResults[i].Value, output[i], 6);
+ }
+ }
+
+ [Fact]
+ public void Ccyc_CalculateReturnsIndicator()
+ {
+ var source = GetTestSeries(100);
+ var (results, indicator) = Ccyc.Calculate(source);
+
+ Assert.NotNull(indicator);
+ Assert.Equal(source.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // G) Span API
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_SpanBatch_LengthMismatch_Throws()
+ {
+ double[] src = [1, 2, 3];
+ double[] outShort = new double[2];
+ Assert.Throws(() => Ccyc.Batch(src.AsSpan(), outShort.AsSpan()));
+ }
+
+ [Fact]
+ public void Ccyc_SpanBatch_InvalidAlpha_Throws()
+ {
+ double[] src = [1, 2, 3];
+ double[] output = new double[3];
+ Assert.Throws(() => Ccyc.Batch(src.AsSpan(), output.AsSpan(), alpha: 0.0));
+ }
+
+ [Fact]
+ public void Ccyc_SpanBatch_EmptyInput_NoThrow()
+ {
+ double[] src = [];
+ double[] output = [];
+ Ccyc.Batch(src.AsSpan(), output.AsSpan());
+ Assert.True(true); // No throw
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // H) Chainability
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_Chainable_ReceivesValues()
+ {
+ var source = GetTestSeries(100);
+ var ema = new Ema(10);
+ var ccyc = new Ccyc(ema, alpha: 0.07);
+
+ for (int i = 0; i < source.Count; i++)
+ {
+ ema.Update(source[i], true);
+ }
+
+ Assert.True(ccyc.IsHot, "Chained CCYC should become hot");
+ Assert.True(double.IsFinite(ccyc.Last.Value));
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // I) CCYC-Specific
+ // ═══════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Ccyc_Trigger_IsDelayedCycle()
+ {
+ var ccyc = new Ccyc();
+ var source = GetTestSeries(50);
+
+ double prevCycle = 0;
+ for (int i = 0; i < source.Count; i++)
+ {
+ ccyc.Update(source[i], true);
+ if (i > 0)
+ {
+ // Trigger should equal previous cycle value
+ Assert.Equal(prevCycle, ccyc.Trigger, 10);
+ }
+ prevCycle = ccyc.Last.Value;
+ }
+ }
+
+ [Fact]
+ public void Ccyc_ConstantInput_ConvergesToZero()
+ {
+ var ccyc = new Ccyc();
+ for (int i = 0; i < 200; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), 100.0), true);
+ }
+
+ // High-pass filter on constant → 0
+ Assert.True(Math.Abs(ccyc.Last.Value) < 1e-6, $"Expected near-zero, got {ccyc.Last.Value}");
+ }
+
+ [Fact]
+ public void Ccyc_SineWave_DetectsCycle()
+ {
+ var ccyc = new Ccyc();
+ int period = 20;
+
+ for (int i = 0; i < 200; i++)
+ {
+ double value = 100 + 10 * Math.Sin(2 * Math.PI * i / period);
+ ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), value), true);
+ }
+
+ // On a sine wave, the cycle output should have significant amplitude
+ Assert.True(Math.Abs(ccyc.Last.Value) > 0.01, "Cycle should detect sine wave");
+ }
+
+ [Fact]
+ public void Ccyc_DifferentAlphas_ProduceDifferentOutputs()
+ {
+ var source = GetTestSeries(200);
+ var resultsFast = Ccyc.Batch(source, alpha: 0.15);
+ var resultsSlow = Ccyc.Batch(source, alpha: 0.03);
+
+ bool anyDiff = false;
+ for (int i = 20; i < source.Count; i++)
+ {
+ if (Math.Abs(resultsFast[i].Value - resultsSlow[i].Value) > 1e-10)
+ {
+ anyDiff = true;
+ break;
+ }
+ }
+ Assert.True(anyDiff, "Different alphas should produce different outputs");
+ }
+
+ [Fact]
+ public void Ccyc_Prime_SetsState()
+ {
+ var ccyc = new Ccyc();
+ double[] primeData = new double[50];
+ for (int i = 0; i < 50; i++)
+ {
+ primeData[i] = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
+ }
+
+ ccyc.Prime(primeData.AsSpan());
+ Assert.True(ccyc.IsHot);
+ Assert.True(double.IsFinite(ccyc.Last.Value));
+ }
+
+ [Fact]
+ public void Ccyc_Bootstrap_DiffersFromSteadyState()
+ {
+ // First 6 bars use bootstrap; bar 7+ use IIR
+ var ccyc = new Ccyc();
+ var values = new double[] { 100, 102, 99, 101, 103, 98, 100, 104, 97 };
+ var results = new List();
+
+ for (int i = 0; i < values.Length; i++)
+ {
+ var r = ccyc.Update(new TValue(DateTime.UtcNow.AddDays(i), values[i]), true);
+ results.Add(r.Value);
+ }
+
+ // All values should be finite
+ foreach (var v in results)
+ {
+ Assert.True(double.IsFinite(v));
+ }
+
+ // At bar 7 (index 6), we enter steady state — should still be finite
+ Assert.True(double.IsFinite(results[6]));
+ }
+
+ [Fact]
+ public void Ccyc_ResetAndReprocess_MatchesOriginal()
+ {
+ var source = GetTestSeries(100);
+ var ccyc = new Ccyc();
+
+ var results1 = ccyc.Update(source);
+ ccyc.Reset();
+ var results2 = ccyc.Update(source);
+
+ Assert.Equal(results1.Count, results2.Count);
+ for (int i = 0; i < results1.Count; i++)
+ {
+ Assert.Equal(results1[i].Value, results2[i].Value, 10);
+ }
+ }
+}
diff --git a/lib/cycles/ccyc/Ccyc.Validation.Tests.cs b/lib/cycles/ccyc/Ccyc.Validation.Tests.cs
new file mode 100644
index 00000000..fa2f194d
--- /dev/null
+++ b/lib/cycles/ccyc/Ccyc.Validation.Tests.cs
@@ -0,0 +1,363 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+///
+/// Validation tests for CCYC - Ehlers Cyber Cycle.
+/// Since CCYC is a proprietary Ehlers algorithm with no standard library implementations,
+/// these tests validate mathematical properties and internal consistency.
+///
+public class CcycValidationTests
+{
+ private const double Tolerance = 1e-9;
+ private const long StartTime = 946_684_800_000_000_0L; // 2000-01-01 UTC in ticks
+ private static readonly TimeSpan Step = TimeSpan.FromMinutes(1);
+
+ #region Mathematical Property Validation
+
+ [Fact]
+ public void Ccyc_ConstantInput_ConvergesToZero()
+ {
+ // High-pass filter on constant input must converge to zero
+ var ccyc = new Ccyc(0.07);
+
+ for (int i = 0; i < 500; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0), true);
+ }
+
+ Assert.True(Math.Abs(ccyc.Last.Value) < 1e-10,
+ $"Constant input should produce zero output, got {ccyc.Last.Value}");
+ }
+
+ [Fact]
+ public void Ccyc_LinearTrend_ConvergesToZero()
+ {
+ // High-pass filter on linear trend should converge to zero (no oscillation)
+ var ccyc = new Ccyc(0.07);
+
+ for (int i = 0; i < 500; i++)
+ {
+ ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + 0.5 * i), true);
+ }
+
+ // After warmup, should be near zero since linear trend has no cycle component
+ Assert.True(Math.Abs(ccyc.Last.Value) < 1.0,
+ $"Linear trend should produce near-zero output, got {ccyc.Last.Value}");
+ }
+
+ [Fact]
+ public void Ccyc_SineWave_ProducesNonZeroOutput()
+ {
+ // A sine wave should produce non-zero cycle output
+ var ccyc = new Ccyc(0.07);
+ int period = 20;
+
+ for (int i = 0; i < 200; i++)
+ {
+ double value = 100 + 10 * Math.Sin(2 * Math.PI * i / period);
+ ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
+ }
+
+ // Cycle output should be non-trivial
+ Assert.True(Math.Abs(ccyc.Last.Value) > 0.01,
+ $"Sine wave should produce non-zero cycle, got {ccyc.Last.Value}");
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(20)]
+ [InlineData(40)]
+ public void Ccyc_SineWave_OutputOscillates(int period)
+ {
+ // Output should oscillate (have zero crossings) for sinusoidal input
+ var ccyc = new Ccyc(0.07);
+ int zeroCrossings = 0;
+ double prev = 0;
+
+ for (int i = 0; i < 300; i++)
+ {
+ double value = 100 + 10 * Math.Sin(2 * Math.PI * i / period);
+ var r = ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
+
+ if (i > 20 && prev * r.Value < 0 && prev != 0)
+ {
+ zeroCrossings++;
+ }
+ prev = r.Value;
+ }
+
+ Assert.True(zeroCrossings > 3,
+ $"Output should oscillate with period={period}, got {zeroCrossings} zero crossings");
+ }
+
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(456)]
+ public void Ccyc_DeterministicOutput(int seed)
+ {
+ // Same input should always produce same output
+ var gbm = new GBM(seed: seed);
+ var bars1 = gbm.Fetch(200, StartTime, Step);
+
+ gbm = new GBM(seed: seed);
+ var bars2 = gbm.Fetch(200, StartTime, Step);
+
+ var ccyc1 = new Ccyc(0.07);
+ var ccyc2 = new Ccyc(0.07);
+
+ for (int i = 0; i < bars1.Count; i++)
+ {
+ var result1 = ccyc1.Update(new TValue(bars1[i].Time, bars1[i].Close));
+ var result2 = ccyc2.Update(new TValue(bars2[i].Time, bars2[i].Close));
+
+ Assert.Equal(result1.Value, result2.Value, Tolerance);
+ }
+ }
+
+ #endregion
+
+ #region High-Pass Filter Property Validation
+
+ [Fact]
+ public void Ccyc_HigherAlpha_ProducesDifferentOutput()
+ {
+ // Different alpha values should produce measurably different cycle outputs
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(200, StartTime, Step);
+
+ var ccycFast = new Ccyc(0.15);
+ var ccycSlow = new Ccyc(0.03);
+
+ double diffEnergy = 0;
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ var tv = new TValue(bars[i].Time, bars[i].Close);
+ var rFast = ccycFast.Update(tv);
+ var rSlow = ccycSlow.Update(tv);
+
+ if (i > 20)
+ {
+ double d = rFast.Value - rSlow.Value;
+ diffEnergy += d * d;
+ }
+ }
+
+ // Different alphas must produce different outputs
+ Assert.True(diffEnergy > 1e-6,
+ $"Different alphas should produce different outputs, diffEnergy={diffEnergy}");
+ }
+
+ [Fact]
+ public void Ccyc_FIR_SmoothsNoise()
+ {
+ // The 4-tap FIR smoother should reduce high-frequency noise
+ // Test: random noise should produce smaller cycle than sine wave
+ var ccycNoise = new Ccyc(0.07);
+ var ccycSine = new Ccyc(0.07);
+
+ var rng = new Random(42);
+ double sineEnergy = 0;
+
+ for (int i = 0; i < 300; i++)
+ {
+ double noiseVal = 100 + rng.NextDouble() * 10;
+ ccycNoise.Update(new TValue(DateTime.UtcNow.AddMinutes(i), noiseVal), true);
+
+ double sineVal = 100 + 10 * Math.Sin(2 * Math.PI * i / 20.0);
+ var sineResult = ccycSine.Update(new TValue(DateTime.UtcNow.AddMinutes(i), sineVal), true);
+
+ if (i > 30)
+ {
+ sineEnergy += sineResult.Value * sineResult.Value;
+ }
+ }
+
+ // Sine wave produces coherent cycle output
+ Assert.True(sineEnergy > 0, "Sine wave should produce energy");
+ }
+
+ #endregion
+
+ #region Trigger Line Validation
+
+ [Fact]
+ public void Ccyc_Trigger_IsOnePeriodDelayed()
+ {
+ var ccyc = new Ccyc(0.07);
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(100, StartTime, Step);
+
+ double prevCycle = 0;
+ for (int i = 0; i < bars.Count; i++)
+ {
+ ccyc.Update(new TValue(bars[i].Time, bars[i].Close));
+
+ if (i > 0)
+ {
+ Assert.Equal(prevCycle, ccyc.Trigger, Tolerance);
+ }
+ prevCycle = ccyc.Last.Value;
+ }
+ }
+
+ [Fact]
+ public void Ccyc_Trigger_CrossoverDetectable()
+ {
+ // On a sine wave, cycle and trigger should cross each other (sign change in diff)
+ var ccyc = new Ccyc(0.07);
+ int crossoverCount = 0;
+ double prevDiff = 0;
+
+ for (int i = 0; i < 300; i++)
+ {
+ double value = 100 + 10 * Math.Sin(2 * Math.PI * i / 20.0);
+ ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
+
+ if (i > 20)
+ {
+ double diff = ccyc.Last.Value - ccyc.Trigger;
+ if (prevDiff != 0 && diff * prevDiff < 0)
+ {
+ crossoverCount++;
+ }
+ prevDiff = diff;
+ }
+ }
+
+ Assert.True(crossoverCount > 0,
+ "Cycle and trigger should cross on sine input");
+ }
+
+ #endregion
+
+ #region Consistency Validation
+
+ [Fact]
+ public void Ccyc_BatchMatchesStreaming_OnGBM()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var source = bars.Close;
+
+ // Streaming
+ var ccycStream = new Ccyc(0.07);
+ var streamResults = new double[source.Count];
+ for (int i = 0; i < source.Count; i++)
+ {
+ var r = ccycStream.Update(source[i], true);
+ streamResults[i] = r.Value;
+ }
+
+ // Batch
+ var batchResults = Ccyc.Batch(source, 0.07);
+
+ for (int i = 0; i < source.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, Tolerance);
+ }
+ }
+
+ [Fact]
+ public void Ccyc_SpanMatchesBatch_OnGBM()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var source = bars.Close;
+
+ // TSeries batch
+ var batchResults = Ccyc.Batch(source, 0.07);
+
+ // Span batch
+ double[] values = new double[source.Count];
+ for (int i = 0; i < source.Count; i++)
+ {
+ values[i] = source[i].Value;
+ }
+
+ double[] output = new double[values.Length];
+ Ccyc.Batch(values.AsSpan(), output.AsSpan(), 0.07);
+
+ for (int i = 0; i < source.Count; i++)
+ {
+ Assert.Equal(batchResults[i].Value, output[i], 6);
+ }
+ }
+
+ [Theory]
+ [InlineData(0.03)]
+ [InlineData(0.07)]
+ [InlineData(0.15)]
+ [InlineData(0.30)]
+ public void Ccyc_AllAlphas_ProduceFiniteOutput(double alpha)
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, StartTime, Step);
+ var ccyc = new Ccyc(alpha);
+
+ for (int i = 0; i < bars.Count; i++)
+ {
+ var r = ccyc.Update(new TValue(bars[i].Time, bars[i].Close));
+ Assert.True(double.IsFinite(r.Value), $"Non-finite at bar {i} with alpha={alpha}");
+ }
+ }
+
+ [Fact]
+ public void Ccyc_ResetAndReprocess_Matches()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(200, StartTime, Step);
+ var source = bars.Close;
+
+ var ccyc = new Ccyc(0.07);
+ var results1 = ccyc.Update(source);
+
+ ccyc.Reset();
+ var results2 = ccyc.Update(source);
+
+ Assert.Equal(results1.Count, results2.Count);
+ for (int i = 0; i < results1.Count; i++)
+ {
+ Assert.Equal(results1[i].Value, results2[i].Value, Tolerance);
+ }
+ }
+
+ #endregion
+
+ #region Bootstrap / Steady-State Transition
+
+ [Fact]
+ public void Ccyc_BootstrapTransition_IsSmooth()
+ {
+ // The transition from bootstrap (bar < 7) to steady-state (bar >= 7) should be smooth
+ var ccyc = new Ccyc(0.07);
+ var results = new List();
+
+ for (int i = 0; i < 20; i++)
+ {
+ double value = 100 + 5 * Math.Sin(2 * Math.PI * i / 20.0);
+ var r = ccyc.Update(new TValue(DateTime.UtcNow.AddMinutes(i), value), true);
+ results.Add(r.Value);
+ }
+
+ // Check that the transition at bar 7 (index 6) doesn't produce a huge jump
+ double jump = Math.Abs(results[6] - results[5]);
+ double avgMagnitude = 0;
+ for (int i = 3; i < 10; i++)
+ {
+ avgMagnitude += Math.Abs(results[i]);
+ }
+ avgMagnitude /= 7;
+
+ // Jump should be within reasonable bounds (not 10x the average)
+ if (avgMagnitude > 1e-10)
+ {
+ Assert.True(jump < 10 * avgMagnitude,
+ $"Bootstrap transition jump={jump} too large vs avg magnitude={avgMagnitude}");
+ }
+ }
+
+ #endregion
+}
diff --git a/lib/cycles/ccyc/Ccyc.cs b/lib/cycles/ccyc/Ccyc.cs
new file mode 100644
index 00000000..b02a0faf
--- /dev/null
+++ b/lib/cycles/ccyc/Ccyc.cs
@@ -0,0 +1,315 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// CCYC: Ehlers Cyber Cycle — isolates the dominant cycle component from price data
+/// using a 4-tap FIR pre-smoother and a 2-pole high-pass IIR filter.
+///
+///
+/// From John F. Ehlers, "Cybernetic Analysis for Stocks and Futures" (Wiley, 2004), Chapter 4.
+///
+/// Algorithm:
+/// 1. 4-bar FIR smoother: smooth = (x + 2x[1] + 2x[2] + x[3]) / 6
+/// Zeros at periods 2 and 3 eliminate aliased noise.
+/// 2. 2-pole high-pass IIR:
+/// cycle = c_hp * (smooth - 2*smooth[1] + smooth[2]) + c_fb1*cycle[1] + c_fb2*cycle[2]
+/// where c_hp = (1-0.5*alpha)^2, c_fb1 = 2(1-alpha), c_fb2 = -(1-alpha)^2
+/// 3. Bootstrap (bars < 7): cycle = (x - 2x[1] + x[2]) / 4
+/// 4. Trigger = cycle[1] (one-bar delay for crossover signals)
+///
+/// Properties:
+/// - O(1) per bar: 6 multiplications, 5 additions, 2 state variables
+/// - Zero allocation in hot path
+/// - Alpha controls high-pass cutoff: lower = smoother/more lag
+/// - Trigger property provides the one-bar-delayed crossover line
+///
+[SkipLocalsInit]
+public sealed class Ccyc : AbstractBase
+{
+ private readonly double _chp; // (1 - 0.5*alpha)^2
+ private readonly double _cfb1; // 2*(1 - alpha)
+ private readonly double _cfb2; // -(1 - alpha)^2
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(
+ double Price0, double Price1, double Price2, double Price3,
+ double Smooth0, double Smooth1, double Smooth2,
+ double Cycle0, double Cycle1, double Cycle2,
+ int Count, double LastValid);
+
+ private State _s;
+ private State _ps;
+
+ /// One-bar-delayed cycle value for crossover detection.
+ public double Trigger { get; private set; }
+
+ ///
+ public override bool IsHot => _s.Count >= WarmupPeriod;
+
+ ///
+ /// Creates a new Ccyc indicator with the specified alpha (damping factor).
+ ///
+ /// Damping factor controlling high-pass cutoff. Must be in (0, 1) exclusive. Default 0.07.
+ public Ccyc(double alpha = 0.07)
+ {
+ if (alpha <= 0.0 || alpha >= 1.0)
+ {
+ throw new ArgumentException("Alpha must be between 0 and 1 (exclusive).", nameof(alpha));
+ }
+
+ double halfAlpha = 1.0 - 0.5 * alpha;
+ _chp = halfAlpha * halfAlpha;
+ double oneMinusAlpha = 1.0 - alpha;
+ _cfb1 = 2.0 * oneMinusAlpha;
+ _cfb2 = -(oneMinusAlpha * oneMinusAlpha);
+
+ Name = $"Ccyc({alpha:F2})";
+ WarmupPeriod = 7;
+ _s = default;
+ _ps = default;
+ }
+
+ ///
+ /// Creates a new Ccyc indicator chained to a publisher source.
+ ///
+ /// Source indicator to subscribe to.
+ /// Damping factor controlling high-pass cutoff. Default 0.07.
+ public Ccyc(ITValuePublisher source, double alpha = 0.07) : this(alpha)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ source.Pub += HandleInput;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void HandleInput(object? sender, in TValueEventArgs e)
+ {
+ Update(e.Value, e.IsNew);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ // State management: save/restore for bar correction
+ if (isNew)
+ {
+ _ps = _s;
+ }
+ else
+ {
+ _s = _ps;
+ }
+
+ var s = _s;
+
+ double price = input.Value;
+
+ // NaN/Infinity guard: substitute last valid value
+ if (!double.IsFinite(price))
+ {
+ price = s.LastValid;
+ }
+ else
+ {
+ s = s with { LastValid = price };
+ }
+
+ // Increment bar count
+ int count = isNew ? s.Count + 1 : s.Count;
+
+ // Shift price history
+ double price3 = s.Price2;
+ double price2 = s.Price1;
+ double price1 = s.Price0;
+ double price0 = price;
+
+ // 4-tap FIR smoother: smooth = (x + 2*x1 + 2*x2 + x3) / 6
+ double smooth = (price0 + 2.0 * price1 + 2.0 * price2 + price3) / 6.0;
+
+ // Shift smooth history
+ double smooth2 = s.Smooth1;
+ double smooth1 = s.Smooth0;
+ double smooth0 = smooth;
+
+ double cycle;
+ if (count < 7)
+ {
+ // Bootstrap: second-difference of raw price
+ cycle = (price0 - 2.0 * price1 + price2) * 0.25;
+ }
+ else
+ {
+ // Steady-state: 2-pole high-pass IIR on smoothed input
+ // cycle = c_hp * (smooth - 2*smooth1 + smooth2) + c_fb1*cycle1 + c_fb2*cycle2
+ double diff = smooth0 - 2.0 * smooth1 + smooth2;
+ cycle = Math.FusedMultiplyAdd(_chp, diff,
+ Math.FusedMultiplyAdd(_cfb1, s.Cycle1, _cfb2 * s.Cycle2));
+ }
+
+ // Guard: if IIR diverges to non-finite, substitute zero
+ if (!double.IsFinite(cycle))
+ {
+ cycle = 0.0;
+ }
+
+ // Shift cycle history
+ double cycle2 = s.Cycle1;
+ double cycle1 = s.Cycle0;
+ double cycle0 = cycle;
+
+ // Trigger = previous cycle value
+ Trigger = cycle1;
+
+ _s = new State(
+ price0, price1, price2, price3,
+ smooth0, smooth1, smooth2,
+ cycle0, cycle1, cycle2,
+ count, s.LastValid);
+
+ Last = new TValue(input.Time, cycle);
+ PubEvent(Last, isNew);
+ return Last;
+ }
+
+ ///
+ /// Processes a full TSeries, returning the cycle component for each bar.
+ ///
+ 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);
+
+ for (int i = 0; i < len; i++)
+ {
+ var result = Update(source[i]);
+ vSpan[i] = result.Value;
+ }
+ source.Times.CopyTo(tSpan);
+
+ return new TSeries(t, v);
+ }
+
+ ///
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (double value in source)
+ {
+ Update(new TValue(DateTime.UtcNow, value));
+ }
+ }
+
+ ///
+ /// Static batch: creates a Ccyc, processes source, returns output TSeries.
+ ///
+ public static TSeries Batch(TSeries source, double alpha = 0.07)
+ {
+ var ind = new Ccyc(alpha);
+ return ind.Update(source);
+ }
+
+ ///
+ /// Static span-based batch: computes Cyber Cycle into output span.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, double alpha = 0.07)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length.", nameof(output));
+ }
+ if (alpha <= 0.0 || alpha >= 1.0)
+ {
+ throw new ArgumentException("Alpha must be between 0 and 1 (exclusive).", nameof(alpha));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ double halfAlpha = 1.0 - 0.5 * alpha;
+ double chp = halfAlpha * halfAlpha;
+ double oneMinusAlpha = 1.0 - alpha;
+ double cfb1 = 2.0 * oneMinusAlpha;
+ double cfb2 = -(oneMinusAlpha * oneMinusAlpha);
+
+ double price0 = 0, price1 = 0, price2 = 0, price3 = 0;
+ double smooth0 = 0, smooth1 = 0, smooth2 = 0;
+ double cycle0 = 0, cycle1 = 0, cycle2 = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (!double.IsFinite(val))
+ {
+ val = price0; // last valid
+ }
+
+ price3 = price2;
+ price2 = price1;
+ price1 = price0;
+ price0 = val;
+
+ double smooth = (price0 + 2.0 * price1 + 2.0 * price2 + price3) / 6.0;
+ smooth2 = smooth1;
+ smooth1 = smooth0;
+ smooth0 = smooth;
+
+ double cycle;
+ int barNum = i + 1;
+ if (barNum < 7)
+ {
+ cycle = (price0 - 2.0 * price1 + price2) * 0.25;
+ }
+ else
+ {
+ double diff = smooth0 - 2.0 * smooth1 + smooth2;
+ cycle = Math.FusedMultiplyAdd(chp, diff,
+ Math.FusedMultiplyAdd(cfb1, cycle1, cfb2 * cycle2));
+ }
+
+ // Guard: if IIR diverges to non-finite, substitute zero
+ if (!double.IsFinite(cycle))
+ {
+ cycle = 0.0;
+ }
+
+ cycle2 = cycle1;
+ cycle1 = cycle0;
+ cycle0 = cycle;
+ output[i] = cycle;
+ }
+ }
+
+ ///
+ /// Static convenience method: returns (TSeries results, Ccyc indicator) for inspection.
+ ///
+ public static (TSeries Results, Ccyc Indicator) Calculate(TSeries source, double alpha = 0.07)
+ {
+ var ind = new Ccyc(alpha);
+ var results = ind.Update(source);
+ return (results, ind);
+ }
+
+ ///
+ public override void Reset()
+ {
+ _s = default;
+ _ps = default;
+ Last = default;
+ Trigger = 0;
+ }
+}
diff --git a/lib/cycles/ccyc/Ccyc.md b/lib/cycles/ccyc/Ccyc.md
index 545b0d69..e30dabd4 100644
--- a/lib/cycles/ccyc/Ccyc.md
+++ b/lib/cycles/ccyc/Ccyc.md
@@ -133,6 +133,28 @@ function CCYC(source, alpha):
- **Cycle crosses below trigger**: potential cycle peak (sell signal)
- **Both near zero**: minimal cyclic energy; trend-dominated regime
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD/SUB | 5 | 1 | 5 |
+| MUL | 6 | 3 | 18 |
+| FMA | 2 | 4 | 8 |
+| **Total** | **13** | — | **~31 cycles** |
+
+O(1) per bar. The 4-tap FIR smoother uses 3 MUL + 2 ADD; the 2-pole IIR high-pass uses 2 FMA + 1 MUL. Bootstrap path (bars < 7) is even cheaper: 2 MUL + 1 SUB.
+
+### Quality Metrics
+
+| Metric | Score | Notes |
+| :--- | :---: | :--- |
+| **Accuracy** | 9/10 | IIR filter faithfully isolates cycle component |
+| **Timeliness** | 9/10 | Only 7-bar warmup; 2 state variables converge fast |
+| **Smoothness** | 8/10 | 4-tap FIR + 2-pole IIR suppresses aliased noise |
+| **Memory** | 10/10 | O(1) state: 12 scalar values in record struct |
+
## Resources
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2004. Chapter 4: "Cyber Cycle."
diff --git a/lib/cycles/cg/cg.md b/lib/cycles/cg/cg.md
index 6bfa57c6..dd923acd 100644
--- a/lib/cycles/cg/cg.md
+++ b/lib/cycles/cg/cg.md
@@ -73,6 +73,28 @@ function CG(source, period):
| Zero crossing down | Momentum shifting bearish |
| Hanging at extremes | Strong trend in progress |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD/SUB | 2×N | 1 | 2N |
+| MUL | N | 3 | 3N |
+| DIV | 1 | 15 | 15 |
+| **Total** | **~3N+1** | — | **~5N+15** |
+
+The `RecalculateSums()` loop iterates over the full buffer each bar, making this O(N) per bar. For default $N = 10$: ~65 cycles. A periodic resync every 1000 bars maintains numerical stability.
+
+### Quality Metrics
+
+| Metric | Score | Notes |
+| :--- | :---: | :--- |
+| **Accuracy** | 10/10 | Exact weighted center-of-mass calculation |
+| **Timeliness** | 9/10 | Leads price movement by construction |
+| **Smoothness** | 7/10 | Raw oscillator; no internal smoothing |
+| **Memory** | 9/10 | O(N) ring buffer + 2 running sums |
+
## Resources
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2002.
diff --git a/lib/cycles/dsp/dsp.md b/lib/cycles/dsp/dsp.md
index b4871509..b654b6d9 100644
--- a/lib/cycles/dsp/dsp.md
+++ b/lib/cycles/dsp/dsp.md
@@ -87,6 +87,29 @@ function DSP(source, period):
| Zero crossing | Cycle phase transition point |
| Divergence from price | Cycle energy waning; potential trend exhaustion |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count | Cost (cycles) | Subtotal |
+| :--- | :---: | :---: | :---: |
+| ADD/SUB | 3 | 1 | 3 |
+| MUL | 4 | 3 | 12 |
+| FMA | 2 | 4 | 8 |
+| DIV | 2 | 15 | 30 |
+| **Total** | **11** | — | **~53 cycles** |
+
+O(1) per bar. Two EMA updates (fast + slow) using FMA, plus warmup bias-correction divisions. After warmup completes, the DIV cost drops to zero, reducing steady-state to ~23 cycles.
+
+### Quality Metrics
+
+| Metric | Score | Notes |
+| :--- | :---: | :--- |
+| **Accuracy** | 9/10 | Bias-corrected EMAs eliminate warmup distortion |
+| **Timeliness** | 8/10 | Quarter-cycle EMA responds quickly; half-cycle provides reference |
+| **Smoothness** | 8/10 | Dual EMA differencing inherently smooths noise |
+| **Memory** | 10/10 | O(1) state: 6 scalar values in record struct |
+
## Resources
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2004.
diff --git a/lib/cycles/eacp/eacp.md b/lib/cycles/eacp/eacp.md
index c862dbd2..43464a49 100644
--- a/lib/cycles/eacp/eacp.md
+++ b/lib/cycles/eacp/eacp.md
@@ -110,6 +110,34 @@ function EACP(source, minPeriod, maxPeriod, enhance):
| Rapidly changing value | Market transitioning between regimes |
| Pegged at maxPeriod | No clear cycle detected; likely trending |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| HP filter (2-pole IIR) | ~8 | Pre-processing trend removal |
+| Super-Smoother (2-pole IIR) | ~6 | Anti-aliasing low-pass |
+| Pearson autocorrelation | ~5M | Mean, variance, cross-product over M samples per lag |
+| Autocorrelation loop (N lags) | ~5NM | Nested: N lags × M-sample windows |
+| DFT cosine transform | ~3NM | N periods × M cosine multiply-accumulates |
+| Cosine evaluation | NM | `Math.Cos` calls (expensive transcendental) |
+| Exponential smoothing | ~2N | FMA per period bin |
+| Cubic enhancement | ~2N | Two multiplies per bin (when enabled) |
+| AGC normalization | ~2N | Max scan + N divides |
+| Center-of-gravity | ~3N | Weighted sum + division |
+| **Total (default N=41, M=48)** | **~16,000** | **Dominated by autocorrelation + DFT** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | Partially: inner DFT cosine loops vectorizable; autocorrelation outer loop sequential |
+| Bottleneck | Pearson autocorrelation: N×M multiply-accumulates with data-dependent means |
+| Parallelism | DFT accumulation per period is independent; `Vector` applicable to inner sums |
+| Memory | O(N) power arrays + O(M) circular buffer for SSF history |
+| Throughput | ~100-200× slower than O(1) IIR indicators; most expensive cycle indicator |
+
## Resources
- **Ehlers, J.F.** *Cycle Analytics for Traders*. Wiley, 2013.
diff --git a/lib/cycles/ebsw/ebsw.md b/lib/cycles/ebsw/ebsw.md
index 900282e8..65629c06 100644
--- a/lib/cycles/ebsw/ebsw.md
+++ b/lib/cycles/ebsw/ebsw.md
@@ -111,6 +111,31 @@ function EBSW(source, hpLength, ssfLength):
| Zero crossing down | Bearish phase transition |
| Railing at $\pm 1$ | Strong directional move overwhelming cycle |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| High-pass filter | ~4 | 1 SUB + 1 MUL + 1 FMA |
+| Super-Smoother (2-pole IIR) | ~5 | 1 ADD + 2 FMA + 1 MUL |
+| Wave (3-bar average) | ~3 | 2 ADD + 1 MUL |
+| Power (3-bar RMS²) | ~5 | 3 MUL + 2 ADD |
+| SQRT normalization | ~4 | 1 SQRT + 1 DIV + 1 branch |
+| Clamp | ~2 | 2 comparisons |
+| State shift | ~4 | 4 register moves |
+| **Total** | **~27** | **O(1) fixed; no loops or allocations** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: HP and SSF are recursive IIR filters with sequential dependencies |
+| Bottleneck | `Math.Sqrt` in AGC normalization (~15 cycles per call) |
+| Parallelism | None: each bar depends on previous bar's filter state |
+| Memory | O(1): 6 scalar state variables + 2 previous filter values |
+| Throughput | Very fast; comparable to single EMA despite 3-stage pipeline |
+
## Resources
- **Ehlers, J.F.** *Cycle Analytics for Traders*. Wiley, 2013.
diff --git a/lib/cycles/homod/homod.md b/lib/cycles/homod/homod.md
index f3f54ecc..e8fbd3f0 100644
--- a/lib/cycles/homod/homod.md
+++ b/lib/cycles/homod/homod.md
@@ -118,6 +118,34 @@ function HOMOD(source, minPeriod, maxPeriod):
| Period drifting to maxPeriod | Trending market; cycle measurement unreliable |
| Rapidly fluctuating period | Noisy or transitioning market regime |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| 4-bar WMA | ~5 | 3 MUL + 1 ADD + 1 DIV (precomputed as ×0.1) |
+| Hilbert FIR (detrender) | ~7 | 4-tap FIR: 4 MUL + 3 ADD |
+| Hilbert FIR (Q1) | ~7 | Same 4-tap structure on det buffer |
+| Hilbert FIR (jI, jQ) | ~14 | Two additional 4-tap Hilbert passes |
+| Phasor EMA (I2, Q2) | ~8 | 2 SUB/ADD + 4 FMA |
+| Homodyne mixing | ~8 | 4 MUL + 2 ADD/SUB per Re/Im |
+| Homodyne EMA smoothing | ~4 | 2 FMA for Re, Im |
+| ATAN2 | ~20 | `Math.Atan2` transcendental (~15-20 cycles) |
+| Period clamp + EMA | ~4 | 2 comparisons + 1 FMA |
+| Buffer management | ~8 | 4 circular buffer writes + index updates |
+| **Total** | **~85** | **O(1) fixed; dominated by ATAN2** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: cascaded IIR filters and Hilbert FIR with sequential state dependencies |
+| Bottleneck | `Math.Atan2` transcendental (~20 cycles); Hilbert FIR circular buffer lookups |
+| Parallelism | None: each bar depends on previous bar's I2, Q2, Re, Im state |
+| Memory | O(1): ~7-element circular buffers × 4 + 6 scalar EMA states (~300 bytes) |
+| Throughput | Moderate; ~3× slower than simple EMA due to multi-stage Hilbert pipeline |
+
## Resources
- **Ehlers, J.F.** *Rocket Science for Traders*. Wiley, 2001.
diff --git a/lib/cycles/ht_dcperiod/HtDcperiod.md b/lib/cycles/ht_dcperiod/HtDcperiod.md
index 36dd8ffc..3b113637 100644
--- a/lib/cycles/ht_dcperiod/HtDcperiod.md
+++ b/lib/cycles/ht_dcperiod/HtDcperiod.md
@@ -102,6 +102,35 @@ function HT_DCPERIOD(source):
| `period` $\approx 30$-$50$ | Long-cycle or trending; period drifting toward upper bound suggests trend |
| Stable value | Regular cyclical market, ideal for oscillator-based strategies |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| 4-bar WMA | ~5 | 3 MUL + 1 ADD + 1 MUL(×0.1) |
+| Hilbert FIR (detrender) | ~7 | 4-tap FIR with period-adaptive coefficients |
+| Hilbert FIR (Q1) | ~7 | Same structure applied to detrender buffer |
+| Hilbert FIR (jI) | ~7 | Applied to I1 history buffer |
+| Hilbert FIR (jQ) | ~7 | Applied to Q1 history buffer |
+| Phasor EMA (I2, Q2) | ~8 | 2 SUB/ADD + 4 FMA |
+| Homodyne mixing + EMA | ~12 | 4 MUL + 2 ADD/SUB + 2 FMA |
+| ATAN | ~15 | `Math.Atan` transcendental |
+| Period division (2π/θ) | ~2 | 1 DIV |
+| Clamp + EMA smoothing | ~4 | 2 comparisons + 1 FMA |
+| Buffer management | ~10 | 4 circular buffer writes + index arithmetic |
+| **Total** | **~84** | **O(1) fixed; identical pipeline to HOMOD** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: full Hilbert cascade is sequentially dependent IIR chain |
+| Bottleneck | `Math.Atan` transcendental + 4 Hilbert FIR passes per bar |
+| Parallelism | None: each bar's phasor depends on previous bar's EMA state |
+| Memory | O(1): 4 circular buffers (7 elements each) + 6 scalar EMA states (~280 bytes) |
+| Throughput | Moderate; ~3× slower than simple EMA; matches HOMOD performance |
+
## Resources
- **Ehlers, J.F.** *Rocket Science for Traders*. Wiley, 2001.
diff --git a/lib/cycles/ht_dcphase/HtDcphase.md b/lib/cycles/ht_dcphase/HtDcphase.md
index 2606a67e..48d2e207 100644
--- a/lib/cycles/ht_dcphase/HtDcphase.md
+++ b/lib/cycles/ht_dcphase/HtDcphase.md
@@ -99,6 +99,30 @@ function HT_DCPHASE(source):
| Rapid phase change | Potential reversal imminent |
| Discontinuity ($315° \to -45°$) | One cycle complete, new cycle begins |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Hilbert cascade (WMA + 4×FIR + phasor + homodyne) | ~84 | Same as HT_DCPERIOD pipeline |
+| DFT sin/cos evaluation | 2P | `Math.Sin` + `Math.Cos` per iteration (~15-20 cycles each) |
+| DFT multiply-accumulate | 2P | realPart/imagPart FMA per iteration |
+| ATAN phase extraction | ~15 | `Math.Atan` transcendental |
+| Phase adjustment + wrapping | ~5 | 2 ADD + 2 comparisons + 1 conditional ADD |
+| **Total (P=20 typical)** | **~184** | **O(P) dominated by DFT sin/cos loop** |
+| **Total (P=50 worst case)** | **~384** | **Upper bound when period near maximum** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | Partially: DFT inner loop sin/cos accumulation is vectorizable with precomputed twiddle factors |
+| Bottleneck | DFT loop: P transcendental calls per bar; Hilbert cascade is sequential |
+| Parallelism | DFT accumulation independent per frequency bin; `Vector` applicable to sin/cos MACs |
+| Memory | O(P): ~50-element smooth price circular buffer + Hilbert state (~1.2 KB) |
+| Throughput | ~2-4× slower than O(1) Hilbert-only indicators (HOMOD, HT_DCPERIOD) due to variable-length DFT |
+
## Resources
- **Ehlers, J.F.** *Rocket Science for Traders*. Wiley, 2001.
diff --git a/lib/cycles/ht_phasor/HtPhasor.md b/lib/cycles/ht_phasor/HtPhasor.md
index 0435406a..783ac075 100644
--- a/lib/cycles/ht_phasor/HtPhasor.md
+++ b/lib/cycles/ht_phasor/HtPhasor.md
@@ -89,6 +89,31 @@ function HT_PHASOR(source):
| `InPhase` | unbounded | Cycle component aligned with price |
| `Quadrature` | unbounded | Rate of change (velocity) of cycle |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| 4-bar WMA | ~5 | 3 MUL + 1 ADD + 1 MUL(×0.1) |
+| Hilbert FIR (detrender) | ~7 | 4-tap FIR: 4 MUL + 3 ADD |
+| Hilbert FIR (Q1) | ~7 | Same 4-tap structure on det buffer |
+| Hilbert FIR (jI) | ~7 | 4-tap on I1 history |
+| Hilbert FIR (jQ) | ~7 | 4-tap on Q1 history |
+| Phasor EMA (I2, Q2) | ~8 | 2 SUB/ADD + 4 FMA |
+| Buffer management | ~10 | 4 circular buffer writes + index arithmetic |
+| **Total** | **~51** | **O(1) fixed; no transcendentals (no period/phase extraction)** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: cascaded IIR EMA smoothing creates sequential dependencies |
+| Bottleneck | Circular buffer indexed lookups for 4 Hilbert FIR passes |
+| Parallelism | None: each bar's phasor depends on previous bar's EMA state |
+| Memory | O(1): 4 circular buffers (7 elements each) + 2 scalar EMA states (~240 bytes) |
+| Throughput | Fastest of the HT family; no transcendental calls (no ATAN/SIN/COS) |
+
## Resources
- **Ehlers, J.F.** *Rocket Science for Traders*. Wiley, 2001.
diff --git a/lib/cycles/ht_sine/HtSine.md b/lib/cycles/ht_sine/HtSine.md
index f93e6eb0..68158f9a 100644
--- a/lib/cycles/ht_sine/HtSine.md
+++ b/lib/cycles/ht_sine/HtSine.md
@@ -92,6 +92,31 @@ function HT_SINE(source):
| `Sine` | $[-1, +1]$ | Current cycle phase position |
| `LeadSine` | $[-1, +1]$ | 45° advanced cycle phase (early warning) |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Hilbert cascade (WMA + 4×FIR + phasor + homodyne) | ~84 | Same pipeline as HT_DCPERIOD |
+| DFT sin/cos accumulation | ~4P | P sin + P cos evaluations + 2P FMA |
+| Phase ATAN extraction | ~15 | `Math.Atan` transcendental |
+| Phase adjustment + unwrapping | ~5 | Quadrant correction + wrapping |
+| Final SIN (sine) | ~15 | `Math.Sin` transcendental |
+| Final SIN (leadSine) | ~15 | `Math.Sin(φ + π/4)` transcendental |
+| **Total (P=20 typical)** | **~214** | **O(P) dominated by DFT + 3 transcendentals** |
+| **Total (P=50 worst case)** | **~454** | **Heaviest of the HT family** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | Partially: DFT inner loop vectorizable; final sin calls are scalar |
+| Bottleneck | DFT loop (P sin/cos calls) + 3 final transcendentals per bar |
+| Parallelism | DFT accumulation independent; dual sin output trivially parallel |
+| Memory | O(P): ~50-element smooth price buffer + ~44-element det buffer + Hilbert state (~1.3 KB) |
+| Throughput | Slowest HT variant; ~2.5× HT_DCPHASE due to extra sin evaluations |
+
## Resources
- **Ehlers, J.F.** *Rocket Science for Traders*. Wiley, 2001.
diff --git a/lib/cycles/lunar/Lunar.md b/lib/cycles/lunar/Lunar.md
index c157fbca..ae29595a 100644
--- a/lib/cycles/lunar/Lunar.md
+++ b/lib/cycles/lunar/Lunar.md
@@ -114,6 +114,31 @@ function LUNAR(timestamp):
| $k \approx 0.5$ (falling) | Last Quarter |
| $k$ falling, $< 0.5$ | Waning Crescent |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Julian date conversion | ~4 | 1 DIV + 1 ADD + 1 SUB + 1 DIV |
+| Horner polynomial (5 elements) | ~25 | 5 FMA chains (3-4 deep each) |
+| Modular reduction (5 elements) | ~5 | 5 `mod 360` operations |
+| SIN evaluations (perturbations) | ~48 | 6 `Math.Sin` calls (~8 cycles each) |
+| Perturbation sum | ~11 | 6 MUL + 5 ADD |
+| Solar longitude (Horner + 2 SIN) | ~20 | 2 FMA + 2 `Math.Sin` + 2 FMA |
+| Phase angle + COS | ~10 | 1 SUB + 1 `Math.Cos` + 1 SUB + 1 MUL |
+| **Total** | **~123** | **O(1) pure arithmetic; no state, no buffers** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | Yes: fully stateless; each timestamp independent; `Vector` applicable to Horner chains |
+| Bottleneck | 8 transcendental calls (6 SIN + 1 SIN + 1 COS); ~64 cycles total |
+| Parallelism | Full: no inter-bar dependencies; ideal for `Vector` batch processing |
+| Memory | O(0): zero state; pure function of timestamp |
+| Throughput | Very fast; bulk evaluation benefits from SIMD Horner + vectorized sin/cos |
+
## Resources
- **Meeus, J.** *Astronomical Algorithms*. 2nd ed., Willmann-Bell, 1998.
diff --git a/lib/cycles/sine/Sine.md b/lib/cycles/sine/Sine.md
index e12cc9cb..975d75a9 100644
--- a/lib/cycles/sine/Sine.md
+++ b/lib/cycles/sine/Sine.md
@@ -121,6 +121,31 @@ function SINE(source, hpPeriod, ssfPeriod):
| Zero crossing down | Bearish phase transition |
| Erratic output | Strong trend overwhelming cycle extraction |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| High-pass filter | ~4 | 1 SUB + 1 MUL + 1 FMA |
+| Super-Smoother (2-pole IIR) | ~5 | 1 ADD + 2 FMA + 1 MUL |
+| Hilbert FIR (quadrature) | ~7 | 4-tap FIR: 4 MUL + 3 ADD |
+| I² + Q² (power) | ~3 | 2 MUL + 1 ADD |
+| SQRT + normalization | ~4 | 1 SQRT + 1 DIV + 1 branch |
+| Buffer management | ~3 | 1 circular buffer write + index update |
+| State shift | ~4 | 4 register moves |
+| **Total** | **~30** | **O(1) fixed; single SQRT is only transcendental** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: HP and SSF are recursive IIR with sequential state dependencies |
+| Bottleneck | `Math.Sqrt` in power normalization (~15 cycles); rest is pure arithmetic |
+| Parallelism | None: each bar's HP/SSF output depends on previous bar |
+| Memory | O(1): 8-element ring buffer + 4 scalar state variables (~96 bytes) |
+| Throughput | Very fast; slightly faster than EBSW (no 3-bar averaging, no clamp) |
+
## Resources
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2004.
diff --git a/lib/cycles/solar/Solar.md b/lib/cycles/solar/Solar.md
index 9e604ad6..12e7e96b 100644
--- a/lib/cycles/solar/Solar.md
+++ b/lib/cycles/solar/Solar.md
@@ -107,6 +107,31 @@ function SOLAR(timestamp):
| $Solar = 0$ (falling) | Autumn equinox crossing |
| Southern Hemisphere | Negate the output |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Julian date conversion | ~4 | 1 DIV + 1 ADD + 1 SUB + 1 DIV |
+| Horner polynomial (L0) | ~5 | 2 FMA + 1 mod |
+| Horner polynomial (M) | ~5 | 2 FMA + 1 mod |
+| SIN evaluations (equation of center) | ~24 | 3 `Math.Sin` calls (~8 cycles each) |
+| Equation of center arithmetic | ~8 | 3 FMA chains + 2 ADD |
+| True longitude addition | ~1 | 1 ADD |
+| Final SIN (seasonal index) | ~10 | 1 degree-to-radian MUL + 1 `Math.Sin` |
+| **Total** | **~57** | **O(1) pure arithmetic; simpler than LUNAR** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | Yes: fully stateless; each timestamp independent; `Vector` applicable |
+| Bottleneck | 4 transcendental calls (3 SIN for equation of center + 1 final SIN); ~32 cycles |
+| Parallelism | Full: no inter-bar dependencies; ideal for `Vector` batch processing |
+| Memory | O(0): zero state; pure function of timestamp |
+| Throughput | Fastest cycle indicator; ~2× faster than LUNAR (fewer perturbation terms) |
+
## Resources
- **Meeus, J.** *Astronomical Algorithms*. 2nd ed., Willmann-Bell, 1998.
diff --git a/lib/cycles/ssfdsp/Ssfdsp.md b/lib/cycles/ssfdsp/Ssfdsp.md
index 7e609c4d..ab87c33a 100644
--- a/lib/cycles/ssfdsp/Ssfdsp.md
+++ b/lib/cycles/ssfdsp/Ssfdsp.md
@@ -118,6 +118,29 @@ function SSFDSP(source, period):
| Divergence with price | Cycle energy waning; trend exhaustion |
| Amplitude shrinking | Cycle losing dominance; transition to trend |
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Input averaging | ~2 | 1 ADD + 1 MUL(×0.5) |
+| Fast SSF (2-pole IIR) | ~5 | 1 MUL(c1f) + 2 FMA(c2f, c3f) |
+| Slow SSF (2-pole IIR) | ~5 | 1 MUL(c1s) + 2 FMA(c2s, c3s) |
+| Subtraction (output) | ~1 | 1 SUB |
+| State shift | ~5 | 5 register moves |
+| **Total** | **~18** | **O(1) fixed; pure FMA arithmetic, zero transcendentals** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: both SSF filters are recursive 2-pole IIR with sequential state dependencies |
+| Bottleneck | None significant; pure multiply-accumulate with precomputed coefficients |
+| Parallelism | None: each bar depends on two previous bars' filter state |
+| Memory | O(1): 4 scalar filter states + 1 previous price (~40 bytes) |
+| Throughput | Among fastest cycle indicators; comparable to dual-EMA DSP; no transcendentals at runtime |
+
## Resources
- **Ehlers, J.F.** *Cybernetic Analysis for Stocks and Futures*. Wiley, 2004.
diff --git a/lib/cycles/stc/stc.md b/lib/cycles/stc/stc.md
index 377e2629..8cfe9e2f 100644
--- a/lib/cycles/stc/stc.md
+++ b/lib/cycles/stc/stc.md
@@ -142,6 +142,37 @@ Setting $k \approx f/2$ targets the half-cycle of the MACD's dominant frequency,
The recursive EMA dependencies and sequential min/max ring buffer updates prevent SIMD vectorization of the streaming path. The `Calculate(Span)` path can parallelize independent MACD computations but must serialize the double-Stochastic pipeline.
+## Performance Profile
+
+### Operation Count (Streaming Mode)
+
+| Operation | Count per bar | Notes |
+|-----------|--------------|-------|
+| Fast EMA | ~3 | 1 FMA + 1 MUL |
+| Slow EMA | ~3 | 1 FMA + 1 MUL |
+| MACD subtraction | ~1 | 1 SUB |
+| Ring buffer add (MACD) | ~1 | 1 write + index update |
+| Min/Max scan (MACD buf) | ~2k | Linear scan of k elements × 2 (min + max) |
+| First Stochastic (%K₁) | ~4 | 1 SUB + 1 DIV + 1 MUL + 1 branch |
+| First EMA smoothing (%D₁) | ~3 | 1 FMA + 1 MUL |
+| Ring buffer add (%D₁) | ~1 | 1 write + index update |
+| Min/Max scan (%D₁ buf) | ~2k | Linear scan of k elements × 2 |
+| Second Stochastic (%K₂) | ~4 | 1 SUB + 1 DIV + 1 MUL + 1 branch |
+| Final smoothing (EMA) | ~3 | 1 FMA + 1 MUL |
+| Clamp | ~2 | 2 comparisons |
+| **Total (k=10 default)** | **~65** | **O(k) dominated by dual min/max scans** |
+| **Total (k=50 worst)** | **~225** | **Linear growth with kPeriod** |
+
+### Batch Mode (SIMD Analysis)
+
+| Aspect | Assessment |
+|--------|------------|
+| SIMD vectorizable | No: recursive EMAs + sequential ring buffer min/max prevent vectorization |
+| Bottleneck | Dual min/max scans over ring buffers (2×k comparisons per bar) |
+| Parallelism | MACD EMA computation is independent of Stochastic pipeline but still sequential IIR |
+| Memory | O(k): two ring buffers of kPeriod doubles + 6 scalar EMA states (~200 bytes at k=10) |
+| Throughput | Moderate; faster than HT family (no transcendentals) but slower than pure IIR (min/max scans) |
+
## Resources
- Schaff, D. — "Schaff Trend Cycle" (currency trading methodology, 1990s)
diff --git a/lib/filters/_index.md b/lib/filters/_index.md
index 9c5dad6c..54d3f44f 100644
--- a/lib/filters/_index.md
+++ b/lib/filters/_index.md
@@ -8,7 +8,6 @@ Signal processing filters adapted for financial time series. These are not indic
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
-| [AGC](agc/Agc.md) | Ehlers Automatic Gain Control | Ehlers. Amplitude normalization via exponential peak tracking. |
| [ALAGUERRE](alaguerre/ALaguerre.md) | Ehlers Adaptive Laguerre Filter | Ehlers. Variable-alpha Laguerre from tracking-error normalization. |
| [BAXTERKING](baxterking/BaxterKing.md) | Baxter-King Band-Pass Filter | Symmetric FIR band-pass. Ideal for business cycle extraction. |
| [CFITZ](cfitz/Cfitz.md) | Christiano-Fitzgerald Filter | Asymmetric full-sample band-pass. Optimal under random-walk assumption. |
diff --git a/lib/momentum/_index.md b/lib/momentum/_index.md
index 87c79312..40da22ed 100644
--- a/lib/momentum/_index.md
+++ b/lib/momentum/_index.md
@@ -22,5 +22,6 @@ Momentum indicators measure the velocity and acceleration of price changes. Unli
| [ROCR](rocr/Rocr.md) | Rate of Change Ratio | Price ratio over N periods. |
| [RSI](rsi/Rsi.md) | Relative Strength Index | Speed and change of price movements, bounded 0-100. |
| [RSX](rsx/Rsx.md) | Relative Strength Quality Index | Noise-free RSI using cascaded IIR filters, zero lag at turning points. |
+| [SAM](sam/Sam.md) | Smoothed Adaptive Momentum | Ehlers. Hilbert Transform cycle detection + adaptive momentum + Super Smoother output. |
| [TSI](tsi/Tsi.md) | True Strength Index | Double-smoothed momentum oscillator. |
| [VEL](vel/Vel.md) | Jurik Velocity | Market acceleration via PWMA vs WMA differential. |
diff --git a/lib/momentum/sam/Sam.Quantower.Tests.cs b/lib/momentum/sam/Sam.Quantower.Tests.cs
new file mode 100644
index 00000000..47b8d8be
--- /dev/null
+++ b/lib/momentum/sam/Sam.Quantower.Tests.cs
@@ -0,0 +1,222 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class SamIndicatorTests
+{
+ [Fact]
+ public void SamIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new SamIndicator();
+
+ Assert.Equal(0.07, indicator.Alpha);
+ Assert.Equal(8, indicator.Cutoff);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("SAM - Smoothed Adaptive Momentum", indicator.Name);
+ Assert.True(indicator.SeparateWindow);
+ Assert.False(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void SamIndicator_MinHistoryDepths_Is100()
+ {
+ var indicator = new SamIndicator();
+ Assert.Equal(100, indicator.MinHistoryDepths);
+ }
+
+ [Fact]
+ public void SamIndicator_ShortName_IncludesParams()
+ {
+ var indicator = new SamIndicator { Alpha = 0.1, Cutoff = 12 };
+ Assert.Equal("SAM(0.1,12)", indicator.ShortName);
+ }
+
+ [Fact]
+ public void SamIndicator_Initialize_CreatesLineSeries()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ Assert.Equal(2, indicator.LinesSeries.Count);
+ Assert.Equal("SAM", indicator.LinesSeries[0].Name);
+ Assert.Equal("Zero", indicator.LinesSeries[1].Name);
+ }
+
+ [Fact]
+ public void SamIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.Equal(1, indicator.LinesSeries[1].Count);
+ }
+
+ [Fact]
+ public void SamIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void SamIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void SamIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ for (int i = 0; i < 20; i++)
+ {
+ indicator.HistoricalData.AddBar(
+ now.AddMinutes(i),
+ 100 + i * 2,
+ 105 + i * 2,
+ 95 + i * 2,
+ 102 + i * 2);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ Assert.Equal(20, indicator.LinesSeries[0].Count);
+
+ for (int i = 0; i < 20; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
+ Assert.Equal(0, indicator.LinesSeries[1].GetValue(i));
+ }
+ }
+
+ [Fact]
+ public void SamIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[]
+ {
+ SourceType.Open,
+ SourceType.High,
+ SourceType.Low,
+ SourceType.Close,
+ SourceType.HL2,
+ SourceType.HLC3,
+ };
+
+ foreach (var source in sources)
+ {
+ var indicator = new SamIndicator { Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ }
+ }
+
+ [Fact]
+ public void SamIndicator_ShowColdValues_False_SetsNaN()
+ {
+ var indicator = new SamIndicator { ShowColdValues = false };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void SamIndicator_FlatPrices_ProducesZeroSam()
+ {
+ var indicator = new SamIndicator();
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ // Feed enough flat bars to pass warmup (100+)
+ for (int i = 0; i < 150; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ double lastSam = indicator.LinesSeries[0].GetValue(0);
+ Assert.Equal(0, lastSam, 5);
+ }
+
+ [Fact]
+ public void SamIndicator_DifferentAlphas_Work()
+ {
+ var alphas = new[] { 0.01, 0.07, 0.2, 0.5, 1.0 };
+
+ foreach (var alpha in alphas)
+ {
+ var indicator = new SamIndicator { Alpha = alpha };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ for (int i = 0; i < 10; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ Assert.Equal(10, indicator.LinesSeries[0].Count);
+ }
+ }
+
+ [Fact]
+ public void SamIndicator_DifferentCutoffs_Work()
+ {
+ var cutoffs = new[] { 2, 8, 16, 30 };
+
+ foreach (var cutoff in cutoffs)
+ {
+ var indicator = new SamIndicator { Cutoff = cutoff };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ for (int i = 0; i < 10; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ Assert.Equal(10, indicator.LinesSeries[0].Count);
+ }
+ }
+}
diff --git a/lib/momentum/sam/Sam.Quantower.cs b/lib/momentum/sam/Sam.Quantower.cs
new file mode 100644
index 00000000..bd373754
--- /dev/null
+++ b/lib/momentum/sam/Sam.Quantower.cs
@@ -0,0 +1,88 @@
+using System.Drawing;
+using TradingPlatform.BusinessLayer;
+using static QuanTAlib.IndicatorExtensions;
+
+namespace QuanTAlib;
+
+///
+/// SAM (Smoothed Adaptive Momentum) Quantower indicator.
+/// Ehlers adaptive momentum oscillator that measures price change over the
+/// dominant cycle period, then smooths with a 2-pole Super Smoother filter.
+///
+public class SamIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Alpha", 0, 0.01, 1.0, 0.01, 2)]
+ public double Alpha { get; set; } = 0.07;
+
+ [InputParameter("Cutoff", 1, 2, 100, 1, 0)]
+ public int Cutoff { get; set; } = 8;
+
+ [DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show Cold Values", sortIndex: 100)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Sam? _sam;
+ private Func? _selector;
+
+ public int MinHistoryDepths => 100; // WarmupPeriod = MaxCyclePeriod * 2
+ public override string ShortName => $"SAM({Alpha},{Cutoff})";
+
+ public SamIndicator()
+ {
+ Name = "SAM - Smoothed Adaptive Momentum";
+ Description = "Ehlers adaptive momentum oscillator using Hilbert Transform cycle detection and Super Smoother";
+ SeparateWindow = true;
+ OnBackGround = false;
+ }
+
+ protected override void OnInit()
+ {
+ _sam = new Sam(Alpha, Cutoff);
+ _selector = Source.GetPriceSelector();
+
+ AddLineSeries(new LineSeries("SAM", IndicatorExtensions.Momentum, 2, LineStyle.Histogramm));
+ AddLineSeries(new LineSeries("Zero", Color.Gray, 1, LineStyle.Dot));
+ }
+
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ if (_sam == null || _selector == null)
+ {
+ return;
+ }
+
+ var item = HistoricalData[0, SeekOriginHistory.End];
+ double value = _selector(item);
+ bool isNew = args.IsNewBar();
+
+ TValue input = new(item.TimeLeft, value);
+ _sam.Update(input, isNew);
+
+ bool isHot = _sam.IsHot;
+
+ LinesSeries[0].SetValue(_sam.Last.Value, isHot, ShowColdValues);
+ LinesSeries[1].SetValue(0);
+
+ if (isHot || ShowColdValues)
+ {
+ double sam = _sam.Last.Value;
+ Color color;
+ if (sam > 0)
+ {
+ color = Color.Green;
+ }
+ else if (sam < 0)
+ {
+ color = Color.Red;
+ }
+ else
+ {
+ color = Color.Gray;
+ }
+
+ LinesSeries[0].SetMarker(0, new IndicatorLineMarker(color));
+ }
+ }
+}
diff --git a/lib/momentum/sam/Sam.Tests.cs b/lib/momentum/sam/Sam.Tests.cs
new file mode 100644
index 00000000..564de7af
--- /dev/null
+++ b/lib/momentum/sam/Sam.Tests.cs
@@ -0,0 +1,606 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+public class SamTests
+{
+ private readonly TSeries _gbm;
+ private const int DataPoints = 500;
+
+ public SamTests()
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
+ var bars = gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+ _gbm = bars.Close;
+ }
+
+ #region A) Constructor Validation
+
+ [Fact]
+ public void Constructor_WithDefaults_SetsProperties()
+ {
+ var sam = new Sam();
+ Assert.Equal("Sam(0.07,8)", sam.Name);
+ Assert.Equal(100, sam.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Constructor_WithCustomParams_SetsProperties()
+ {
+ var sam = new Sam(alpha: 0.1, cutoff: 12);
+ Assert.Equal("Sam(0.1,12)", sam.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithZeroAlpha_ThrowsArgumentException()
+ {
+ var ex = Assert.Throws(() => new Sam(alpha: 0));
+ Assert.Equal("alpha", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithNegativeAlpha_ThrowsArgumentException()
+ {
+ var ex = Assert.Throws(() => new Sam(alpha: -0.1));
+ Assert.Equal("alpha", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithAlphaGreaterThanOne_ThrowsArgumentException()
+ {
+ var ex = Assert.Throws(() => new Sam(alpha: 1.5));
+ Assert.Equal("alpha", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithAlphaOne_DoesNotThrow()
+ {
+ var sam = new Sam(alpha: 1.0);
+ Assert.NotNull(sam);
+ }
+
+ [Fact]
+ public void Constructor_WithCutoffLessThanTwo_ThrowsArgumentException()
+ {
+ var ex = Assert.Throws(() => new Sam(cutoff: 1));
+ Assert.Equal("cutoff", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithCutoffTwo_DoesNotThrow()
+ {
+ var sam = new Sam(cutoff: 2);
+ Assert.NotNull(sam);
+ }
+
+ [Fact]
+ public void Constructor_WithSource_SubscribesToEvents()
+ {
+ var source = new TSeries(DataPoints);
+ var sam = new Sam(source);
+ Assert.NotNull(sam);
+ }
+
+ #endregion
+
+ #region B) Basic Calculation
+
+ [Fact]
+ public void Update_ReturnsFiniteValue()
+ {
+ var sam = new Sam();
+ var tv = sam.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(tv.Value));
+ }
+
+ [Fact]
+ public void Update_FirstValue_ReturnsZero()
+ {
+ var sam = new Sam();
+ var tv = sam.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.Equal(0.0, tv.Value);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var sam = new Sam();
+ sam.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(sam.Last.Value));
+ }
+
+ [Fact]
+ public void Name_IsAccessible()
+ {
+ var sam = new Sam();
+ Assert.Equal("Sam(0.07,8)", sam.Name);
+ }
+
+ [Fact]
+ public void DominantCycle_IsAccessible()
+ {
+ var sam = new Sam();
+ for (int i = 0; i < 200; i++)
+ {
+ sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1));
+ }
+ Assert.True(sam.DominantCycle > 0);
+ }
+
+ [Fact]
+ public void Update_ConstantInput_ProducesZeroOutput()
+ {
+ var sam = new Sam();
+ TValue result = default;
+ for (int i = 0; i < 300; i++)
+ {
+ result = sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
+ }
+ // Constant input → zero momentum → smoothed zero output
+ Assert.Equal(0.0, result.Value, 8);
+ }
+
+ #endregion
+
+ #region C) State + Bar Correction (critical)
+
+ [Fact]
+ public void Update_WithIsNewTrue_AdvancesState()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ sam.Update(new TValue(time, 100.0), true);
+ sam.Update(new TValue(time.AddSeconds(1), 105.0), true);
+ sam.Update(new TValue(time.AddSeconds(2), 110.0), true);
+
+ Assert.NotEqual(default, sam.Last);
+ }
+
+ [Fact]
+ public void Update_WithIsNewFalse_UpdatesCurrentState()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ // Feed enough data to get past trivial warmup
+ for (int i = 0; i < 120; i++)
+ {
+ sam.Update(new TValue(time.AddSeconds(i), 100.0 + Math.Sin(i * 0.3) * 10), true);
+ }
+
+ var first = sam.Update(new TValue(time.AddSeconds(120), 115.0), true);
+ var corrected = sam.Update(new TValue(time.AddSeconds(120), 130.0), false);
+
+ // Different input should produce different output
+ Assert.NotEqual(first.Value, corrected.Value);
+ }
+
+ [Fact]
+ public void Update_IterativeCorrections_RestoresPreviousState()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ for (int i = 0; i < 120; i++)
+ {
+ sam.Update(new TValue(time.AddSeconds(i), 100.0 + Math.Sin(i * 0.3) * 10), true);
+ }
+
+ var baseline = sam.Update(new TValue(time.AddSeconds(120), 105.0), true);
+
+ // Apply multiple corrections
+ sam.Update(new TValue(time.AddSeconds(120), 110.0), false);
+ sam.Update(new TValue(time.AddSeconds(120), 120.0), false);
+ var restored = sam.Update(new TValue(time.AddSeconds(120), 105.0), false);
+
+ Assert.Equal(baseline.Value, restored.Value, 10);
+ }
+
+ [Fact]
+ public void Reset_ClearsStateAndLastValidTracking()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ for (int i = 0; i < 120; i++)
+ {
+ sam.Update(new TValue(time.AddSeconds(i), 100.0 + i));
+ }
+
+ sam.Reset();
+
+ Assert.Equal(default, sam.Last);
+ Assert.False(sam.IsHot);
+ }
+
+ #endregion
+
+ #region D) Warmup / Convergence
+
+ [Fact]
+ public void IsHot_ReturnsFalseDuringWarmup()
+ {
+ var sam = new Sam();
+ for (int i = 0; i < 99; i++)
+ {
+ sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
+ Assert.False(sam.IsHot);
+ }
+ }
+
+ [Fact]
+ public void IsHot_ReturnsTrueAfterWarmup()
+ {
+ var sam = new Sam();
+ for (int i = 0; i < 101; i++)
+ {
+ sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
+ }
+ Assert.True(sam.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_Is100()
+ {
+ var sam = new Sam();
+ Assert.Equal(100, sam.WarmupPeriod);
+ }
+
+ #endregion
+
+ #region E) Robustness (critical)
+
+ [Fact]
+ public void Update_WithNaN_UsesLastValidValue()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ for (int i = 0; i < 120; i++)
+ {
+ sam.Update(new TValue(time.AddSeconds(i), 100.0 + Math.Sin(i * 0.2) * 5), true);
+ }
+
+ var afterNaN = sam.Update(new TValue(time.AddSeconds(120), double.NaN), true);
+ Assert.True(double.IsFinite(afterNaN.Value));
+ }
+
+ [Fact]
+ public void Update_WithInfinity_UsesLastValidValue()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ for (int i = 0; i < 120; i++)
+ {
+ sam.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.1), true);
+ }
+
+ var afterInf = sam.Update(new TValue(time.AddSeconds(120), double.PositiveInfinity), true);
+ Assert.True(double.IsFinite(afterInf.Value));
+ }
+
+ [Fact]
+ public void Update_BatchNaN_HandlesSafely()
+ {
+ var sam = new Sam();
+ var time = DateTime.UtcNow;
+
+ for (int i = 0; i < 200; i++)
+ {
+ var value = i % 5 == 0 ? double.NaN : 100.0 + i * 0.1;
+ var tv = sam.Update(new TValue(time.AddSeconds(i), value), true);
+ Assert.True(double.IsFinite(tv.Value));
+ }
+ }
+
+ #endregion
+
+ #region F) Consistency — All 4 modes must match (critical)
+
+ [Fact]
+ public void AllModes_ProduceSameResults()
+ {
+ // Mode 1: Batch via TSeries
+ var batchResult = Sam.Batch(_gbm);
+
+ // Mode 2: Streaming
+ var streamingSam = new Sam();
+ var streamingResult = new TSeries(DataPoints);
+ for (int i = 0; i < _gbm.Count; i++)
+ {
+ var tv = streamingSam.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
+ streamingResult.Add(tv, true);
+ }
+
+ // Mode 3: Span-based
+ double[] spanOutput = new double[DataPoints];
+ Sam.Batch(_gbm.Values, spanOutput, 0.07, 8);
+
+ // Mode 4: Event-driven
+ var eventSam = new Sam();
+ var eventResult = new TSeries(DataPoints);
+ eventSam.Pub += (object? _, in TValueEventArgs e) => eventResult.Add(e.Value, e.IsNew);
+ for (int i = 0; i < _gbm.Count; i++)
+ {
+ eventSam.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
+ }
+
+ // Compare all values
+ for (int i = 0; i < DataPoints; i++)
+ {
+ Assert.Equal(batchResult[i].Value, streamingResult[i].Value, 10);
+ Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
+ Assert.Equal(batchResult[i].Value, eventResult[i].Value, 10);
+ }
+ }
+
+ #endregion
+
+ #region G) Span API Tests
+
+ [Fact]
+ public void Calculate_Span_ValidatesOutputLength()
+ {
+ var ex = Assert.Throws(() =>
+ {
+ ReadOnlySpan source = stackalloc double[] { 1, 2, 3, 4, 5 };
+ Span output = stackalloc double[3]; // too short
+ Sam.Batch(source, output);
+ });
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Calculate_Span_ValidatesAlpha()
+ {
+ var ex = Assert.Throws(() =>
+ {
+ ReadOnlySpan source = stackalloc double[] { 1, 2, 3, 4, 5 };
+ Span output = stackalloc double[5];
+ Sam.Batch(source, output, alpha: 0);
+ });
+ Assert.Equal("alpha", ex.ParamName);
+ }
+
+ [Fact]
+ public void Calculate_Span_ValidatesCutoff()
+ {
+ var ex = Assert.Throws(() =>
+ {
+ ReadOnlySpan source = stackalloc double[] { 1, 2, 3, 4, 5 };
+ Span output = stackalloc double[5];
+ Sam.Batch(source, output, cutoff: 1);
+ });
+ Assert.Equal("cutoff", ex.ParamName);
+ }
+
+ [Fact]
+ public void Calculate_Span_MatchesTSeries()
+ {
+ var batchResult = Sam.Batch(_gbm);
+
+ double[] spanOutput = new double[DataPoints];
+ Sam.Batch(_gbm.Values, spanOutput);
+
+ for (int i = 0; i < DataPoints; i++)
+ {
+ Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
+ }
+ }
+
+ [Fact]
+ public void Calculate_Span_HandlesNaN()
+ {
+ double[] source = new double[100];
+ double[] output = new double[100];
+
+ for (int i = 0; i < 100; i++)
+ {
+ source[i] = i % 7 == 0 ? double.NaN : 100.0 + i;
+ }
+
+ Sam.Batch(source, output);
+
+ for (int i = 0; i < 100; i++)
+ {
+ Assert.True(double.IsFinite(output[i]));
+ }
+ }
+
+ [Fact]
+ public void Calculate_Span_LargeData_NoStackOverflow()
+ {
+ int largeSize = 10000;
+ double[] source = new double[largeSize];
+ double[] output = new double[largeSize];
+
+ for (int i = 0; i < largeSize; i++)
+ {
+ source[i] = 100.0 + Math.Sin(i * 0.1) * 20;
+ }
+
+ Sam.Batch(source, output);
+
+ Assert.Equal(largeSize, output.Length);
+ for (int i = 0; i < largeSize; i++)
+ {
+ Assert.True(double.IsFinite(output[i]));
+ }
+ }
+
+ [Fact]
+ public void Calculate_Span_EmptyInput_DoesNotThrow()
+ {
+ ReadOnlySpan source = [];
+ Span output = [];
+ Sam.Batch(source, output);
+ Assert.True(true); // Verify no exception thrown
+ }
+
+ #endregion
+
+ #region H) Chainability
+
+ [Fact]
+ public void Pub_FiresOnUpdate()
+ {
+ var sam = new Sam();
+ bool eventFired = false;
+
+ sam.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
+ sam.Update(new TValue(DateTime.UtcNow, 100.0));
+
+ Assert.True(eventFired);
+ }
+
+ [Fact]
+ public void EventBasedChaining_Works()
+ {
+ var source = new TSeries(10);
+ var sam = new Sam(source);
+ var results = new List();
+
+ sam.Pub += (object? _, in TValueEventArgs e) => results.Add(e.Value.Value);
+
+ for (int i = 0; i < 10; i++)
+ {
+ source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
+ }
+
+ Assert.Equal(10, results.Count);
+ }
+
+ #endregion
+
+ #region Calculate Method Tests
+
+ [Fact]
+ public void Calculate_ReturnsTupleWithResultsAndIndicator()
+ {
+ var (results, indicator) = Sam.Calculate(_gbm);
+
+ Assert.Equal(DataPoints, results.Count);
+ Assert.NotNull(indicator);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Prime_InitializesState()
+ {
+ var sam = new Sam();
+ double[] primeData = new double[150];
+ for (int i = 0; i < 150; i++)
+ {
+ primeData[i] = 100.0 + Math.Sin(i * 0.2) * 10;
+ }
+
+ sam.Prime(primeData);
+
+ Assert.NotEqual(default, sam.Last);
+ Assert.True(sam.IsHot);
+ }
+
+ [Fact]
+ public void Prime_SameAsSequentialUpdates()
+ {
+ var sam1 = new Sam();
+ var sam2 = new Sam();
+ double[] data = new double[150];
+ for (int i = 0; i < 150; i++)
+ {
+ data[i] = 100.0 + Math.Sin(i * 0.2) * 10;
+ }
+
+ sam1.Prime(data);
+
+ foreach (var value in data)
+ {
+ sam2.Update(new TValue(DateTime.MinValue, value));
+ }
+
+ Assert.Equal(sam1.Last.Value, sam2.Last.Value, 10);
+ }
+
+ #endregion
+
+ #region SAM-Specific Behavior Tests
+
+ [Fact]
+ public void Sam_TrendingInput_ProducesNonZeroOutput()
+ {
+ var sam = new Sam();
+ TValue result = default;
+
+ for (int i = 0; i < 200; i++)
+ {
+ result = sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 2), true);
+ }
+
+ // Strong trend should produce non-zero smoothed momentum
+ Assert.NotEqual(0.0, result.Value);
+ }
+
+ [Fact]
+ public void Sam_SinusoidalInput_OscillatesAroundZero()
+ {
+ var sam = new Sam();
+ int positiveCount = 0;
+ int negativeCount = 0;
+
+ for (int i = 0; i < 500; i++)
+ {
+ var result = sam.Update(
+ new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.2) * 20), true);
+
+ if (sam.IsHot)
+ {
+ if (result.Value > 0)
+ {
+ positiveCount++;
+ }
+ else if (result.Value < 0)
+ {
+ negativeCount++;
+ }
+ }
+ }
+
+ // For sinusoidal input, should oscillate both positive and negative
+ Assert.True(positiveCount > 0, "Expected some positive values");
+ Assert.True(negativeCount > 0, "Expected some negative values");
+ }
+
+ [Fact]
+ public void Sam_DominantCycle_StabilizesAfterWarmup()
+ {
+ var sam = new Sam();
+
+ // Feed sinusoidal data with known period ~20
+ for (int i = 0; i < 300; i++)
+ {
+ sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i),
+ 100.0 + Math.Sin(i * 2.0 * Math.PI / 20.0) * 10), true);
+ }
+
+ // After warmup, dominant cycle should have stabilized to a finite positive value
+ Assert.True(sam.DominantCycle >= 6 && sam.DominantCycle <= 50,
+ $"DominantCycle {sam.DominantCycle} should be within [6, 50]");
+ }
+
+ [Fact]
+ public void Sam_AllOutputFinite_WithGBMData()
+ {
+ var sam = new Sam();
+
+ for (int i = 0; i < _gbm.Count; i++)
+ {
+ var result = sam.Update(_gbm[i]);
+ Assert.True(double.IsFinite(result.Value),
+ $"Non-finite value at bar {i}: {result.Value}");
+ }
+ }
+
+ #endregion
+}
diff --git a/lib/momentum/sam/Sam.Validation.Tests.cs b/lib/momentum/sam/Sam.Validation.Tests.cs
new file mode 100644
index 00000000..31252e5d
--- /dev/null
+++ b/lib/momentum/sam/Sam.Validation.Tests.cs
@@ -0,0 +1,410 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+///
+/// Validation tests for SAM - Smoothed Adaptive Momentum.
+/// Since SAM is a proprietary Ehlers algorithm with no standard library implementations,
+/// these tests validate mathematical properties and internal consistency.
+///
+public class SamValidationTests
+{
+ private const double Tolerance = 1e-9;
+
+ #region Mathematical Property Validation
+
+ [Fact]
+ public void Sam_OutputIsFinite_ForAllGBMData()
+ {
+ var sam = new Sam();
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ Assert.True(double.IsFinite(result.Value),
+ $"Non-finite SAM value at {bar.Time}: {result.Value}");
+ }
+ }
+
+ [Fact]
+ public void Sam_ConstantPrice_ConvergesToZero()
+ {
+ // With constant price, momentum is zero → Super Smoother converges to zero
+ var sam = new Sam();
+ TValue result = default;
+
+ for (int i = 0; i < 500; i++)
+ {
+ result = sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
+ }
+
+ Assert.Equal(0.0, result.Value, 8);
+ }
+
+ [Fact]
+ public void Sam_SmoothTransitions()
+ {
+ // SAM output should be smooth due to Super Smoother filter
+ var sam = new Sam();
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ double? prevValue = null;
+ int largeJumps = 0;
+
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+
+ if (prevValue.HasValue && sam.IsHot)
+ {
+ double change = Math.Abs(result.Value - prevValue.Value);
+ // Super Smoother should prevent extremely large jumps
+ if (change > 50)
+ {
+ largeJumps++;
+ }
+ }
+ prevValue = result.Value;
+ }
+
+ // Allow at most 5% large jumps
+ Assert.True(largeJumps < 25, $"Too many large jumps: {largeJumps}");
+ }
+
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(456)]
+ public void Sam_DeterministicOutput(int seed)
+ {
+ // Same input should always produce same output
+ var gbm = new GBM(seed: seed);
+ var bars1 = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ gbm = new GBM(seed: seed);
+ var bars2 = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ var sam1 = new Sam();
+ var sam2 = new Sam();
+
+ for (int i = 0; i < 200; i++)
+ {
+ var r1 = sam1.Update(new TValue(bars1[i].Time, bars1[i].Close));
+ var r2 = sam2.Update(new TValue(bars2[i].Time, bars2[i].Close));
+
+ Assert.Equal(r1.Value, r2.Value, 12);
+ }
+ }
+
+ [Fact]
+ public void Sam_DominantCycle_WithinBounds()
+ {
+ // Dominant cycle should always be within [6, 50] (MinCyclePeriod, MaxCyclePeriod)
+ var sam = new Sam();
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ foreach (var bar in bars)
+ {
+ sam.Update(new TValue(bar.Time, bar.Close));
+
+ if (sam.IsHot)
+ {
+ Assert.True(sam.DominantCycle >= 6 && sam.DominantCycle <= 50,
+ $"DominantCycle {sam.DominantCycle} out of bounds [6, 50]");
+ }
+ }
+ }
+
+ #endregion
+
+ #region Alpha Parameter Sensitivity
+
+ [Theory]
+ [InlineData(0.01)]
+ [InlineData(0.07)]
+ [InlineData(0.2)]
+ [InlineData(0.5)]
+ [InlineData(1.0)]
+ public void Sam_DifferentAlphas_ProduceFiniteResults(double alpha)
+ {
+ var sam = new Sam(alpha: alpha);
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ Assert.True(double.IsFinite(result.Value),
+ $"Non-finite SAM(alpha={alpha}) at {bar.Time}: {result.Value}");
+ }
+ }
+
+ [Fact]
+ public void Sam_DifferentAlphas_ProduceDivergentOutputs()
+ {
+ // Different alpha values affect cycle detection EMA smoothing,
+ // producing different dominant cycle estimates and thus different outputs
+ var samSlow = new Sam(alpha: 0.01);
+ var samFast = new Sam(alpha: 0.5);
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ double sumAbsDivergence = 0;
+ int hotCount = 0;
+
+ foreach (var bar in bars)
+ {
+ var rSlow = samSlow.Update(new TValue(bar.Time, bar.Close));
+ var rFast = samFast.Update(new TValue(bar.Time, bar.Close));
+
+ if (samSlow.IsHot && samFast.IsHot)
+ {
+ sumAbsDivergence += Math.Abs(rSlow.Value - rFast.Value);
+ hotCount++;
+ }
+ }
+
+ // Different alphas should produce meaningfully different outputs
+ double avgDivergence = sumAbsDivergence / hotCount;
+ Assert.True(avgDivergence > 0.01,
+ $"Average divergence ({avgDivergence:F6}) too small — alpha should affect output");
+ }
+
+ #endregion
+
+ #region Cutoff Parameter Sensitivity
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(8)]
+ [InlineData(16)]
+ [InlineData(30)]
+ public void Sam_DifferentCutoffs_ProduceFiniteResults(int cutoff)
+ {
+ var sam = new Sam(cutoff: cutoff);
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ Assert.True(double.IsFinite(result.Value),
+ $"Non-finite SAM(cutoff={cutoff}) at {bar.Time}: {result.Value}");
+ }
+ }
+
+ [Fact]
+ public void Sam_LargerCutoff_SmoothesMore()
+ {
+ // Larger Super Smoother cutoff = more smoothing = less bar-to-bar variation
+ var samSharp = new Sam(cutoff: 2);
+ var samSmooth = new Sam(cutoff: 30);
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ double sumAbsDiffSharp = 0;
+ double sumAbsDiffSmooth = 0;
+ double? prevSharp = null;
+ double? prevSmooth = null;
+
+ foreach (var bar in bars)
+ {
+ var rSharp = samSharp.Update(new TValue(bar.Time, bar.Close));
+ var rSmooth = samSmooth.Update(new TValue(bar.Time, bar.Close));
+
+ if (samSharp.IsHot && samSmooth.IsHot)
+ {
+ if (prevSharp.HasValue)
+ {
+ sumAbsDiffSharp += Math.Abs(rSharp.Value - prevSharp.Value);
+ sumAbsDiffSmooth += Math.Abs(rSmooth.Value - prevSmooth!.Value);
+ }
+ prevSharp = rSharp.Value;
+ prevSmooth = rSmooth.Value;
+ }
+ }
+
+ // Larger cutoff should produce smoother (less variable) output
+ Assert.True(sumAbsDiffSmooth < sumAbsDiffSharp,
+ $"Smooth SAM variation ({sumAbsDiffSmooth:F4}) should be less than sharp ({sumAbsDiffSharp:F4})");
+ }
+
+ #endregion
+
+ #region Batch vs Streaming Consistency
+
+ [Fact]
+ public void Sam_Batch_MatchesStreaming()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+ var close = bars.Close;
+
+ // Batch
+ var batchResult = Sam.Batch(close);
+
+ // Streaming
+ var sam = new Sam();
+ for (int i = 0; i < close.Count; i++)
+ {
+ var result = sam.Update(close[i]);
+ Assert.Equal(batchResult[i].Value, result.Value, Tolerance);
+ }
+ }
+
+ [Fact]
+ public void Sam_SpanBatch_MatchesTSeriesBatch()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+ var close = bars.Close;
+
+ var batchResult = Sam.Batch(close);
+
+ double[] spanOutput = new double[close.Count];
+ Sam.Batch(close.Values, spanOutput);
+
+ for (int i = 0; i < close.Count; i++)
+ {
+ Assert.Equal(batchResult[i].Value, spanOutput[i], Tolerance);
+ }
+ }
+
+ [Fact]
+ public void Sam_Calculate_MatchesBatch()
+ {
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+ var close = bars.Close;
+
+ var batchResult = Sam.Batch(close);
+ var (calcResult, indicator) = Sam.Calculate(close);
+
+ Assert.Equal(batchResult.Count, calcResult.Count);
+ for (int i = 0; i < batchResult.Count; i++)
+ {
+ Assert.Equal(batchResult[i].Value, calcResult[i].Value, Tolerance);
+ }
+ Assert.True(indicator.IsHot);
+ }
+
+ #endregion
+
+ #region Oscillator Properties
+
+ [Fact]
+ public void Sam_MeanRevertingBehavior()
+ {
+ // SAM is a momentum oscillator; over long series it should oscillate around zero
+ var sam = new Sam();
+ var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.3, seed: 42);
+ var bars = gbm.Fetch(2000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ double sum = 0;
+ int hotCount = 0;
+
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ if (sam.IsHot)
+ {
+ sum += result.Value;
+ hotCount++;
+ }
+ }
+
+ // Mean of oscillator should be near zero for zero-drift GBM
+ double mean = sum / hotCount;
+ Assert.True(Math.Abs(mean) < 5.0,
+ $"SAM mean ({mean:F4}) too far from zero for zero-drift GBM");
+ }
+
+ [Fact]
+ public void Sam_UptrendProducesPositiveBias()
+ {
+ // Strong uptrend should produce positive SAM values
+ var sam = new Sam();
+ int positiveCount = 0;
+ int hotCount = 0;
+
+ for (int i = 0; i < 300; i++)
+ {
+ var result = sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 3.0), true);
+ if (sam.IsHot)
+ {
+ hotCount++;
+ if (result.Value > 0)
+ {
+ positiveCount++;
+ }
+ }
+ }
+
+ // Uptrend should produce mostly positive momentum
+ double ratio = (double)positiveCount / hotCount;
+ Assert.True(ratio > 0.5, $"Positive ratio {ratio:P} too low for uptrend");
+ }
+
+ [Fact]
+ public void Sam_DowntrendProducesNegativeBias()
+ {
+ // Strong downtrend should produce negative SAM values
+ var sam = new Sam();
+ int negativeCount = 0;
+ int hotCount = 0;
+
+ for (int i = 0; i < 300; i++)
+ {
+ var result = sam.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 500.0 - i * 3.0), true);
+ if (sam.IsHot)
+ {
+ hotCount++;
+ if (result.Value < 0)
+ {
+ negativeCount++;
+ }
+ }
+ }
+
+ // Downtrend should produce mostly negative momentum
+ double ratio = (double)negativeCount / hotCount;
+ Assert.True(ratio > 0.5, $"Negative ratio {ratio:P} too low for downtrend");
+ }
+
+ #endregion
+
+ #region Reset Consistency
+
+ [Fact]
+ public void Sam_ResetAndRecalculate_MatchesOriginal()
+ {
+ var sam = new Sam();
+ var gbm = new GBM(seed: 42);
+ var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
+
+ // First pass
+ double lastValue1 = 0;
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ lastValue1 = result.Value;
+ }
+
+ // Reset and replay
+ sam.Reset();
+ double lastValue2 = 0;
+ foreach (var bar in bars)
+ {
+ var result = sam.Update(new TValue(bar.Time, bar.Close));
+ lastValue2 = result.Value;
+ }
+
+ Assert.Equal(lastValue1, lastValue2, Tolerance);
+ }
+
+ #endregion
+}
diff --git a/lib/momentum/sam/Sam.cs b/lib/momentum/sam/Sam.cs
new file mode 100644
index 00000000..889c5d99
--- /dev/null
+++ b/lib/momentum/sam/Sam.cs
@@ -0,0 +1,428 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// SAM: Smoothed Adaptive Momentum - Ehlers adaptive momentum oscillator that
+/// measures price change over the dominant cycle period, then smooths with a
+/// 2-pole Super Smoother filter.
+///
+///
+/// Algorithm (Ehlers, "Cybernetic Analysis for Stocks and Futures", 2004, Ch.12):
+/// 1. 4-bar FIR smoother: (src + 2*src[1] + 2*src[2] + src[3]) / 6
+/// 2. Hilbert Transform via 7-tap FIR (0.0962 / 0.5769 coefficients)
+/// 3. Homodyne Discriminator: Re/Im from phasor correlation, period = 2π/atan(Im/Re)
+/// 4. Double-smoothed dominant cycle: instPeriod(0.33) → dcPeriod(0.15)
+/// 5. Adaptive momentum: src - src[dcPeriod]
+/// 6. 2-pole Super Smoother with configurable cutoff
+///
+/// Properties:
+/// - Zero-lag momentum that adapts to dominant cycle length
+/// - Oscillates around zero; no fixed bias from fractional-cycle measurement
+/// - Super Smoother output removes high-frequency noise without phase distortion
+///
+/// Reference Pine Script implementation
+[SkipLocalsInit]
+public sealed class Sam : AbstractBase
+{
+ private readonly double _alpha;
+ private readonly double _alphaDecay; // 1 - alpha
+ private readonly RingBuffer _priceBuf; // lookback buffer for adaptive momentum
+
+ // Super Smoother coefficients (precomputed from cutoff)
+ private readonly double _ssC1;
+ private readonly double _ssC2;
+ private readonly double _ssC3;
+
+ private const double TwoPi = 2.0 * Math.PI;
+ private const double HalfPi = Math.PI / 2.0;
+ private const double Sqrt2 = 1.4142135623730951;
+ private const int MaxCyclePeriod = 50;
+ private const int MinCyclePeriod = 6;
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(
+ // Price history for 4-bar FIR smoother
+ double Price0, double Price1, double Price2, double Price3,
+ // Smooth price history for detrender (7 taps)
+ double Sp0, double Sp1, double Sp2, double Sp3, double Sp4, double Sp5, double Sp6,
+ // Detrender history for Q1 (7 taps)
+ double Det0, double Det1, double Det2, double Det3, double Det4, double Det5, double Det6,
+ // I1 history for JI (7 taps)
+ double I1_0, double I1_1, double I1_2, double I1_3, double I1_4, double I1_5, double I1_6,
+ // Q1 history for JQ (7 taps)
+ double Q1_0, double Q1_1, double Q1_2, double Q1_3, double Q1_4, double Q1_5, double Q1_6,
+ // I2, Q2 smoothed phasor
+ double I2, double Q2,
+ // Re, Im smoothed homodyne components
+ double Re, double Im,
+ // Period tracking: raw → instPeriod → dcPeriod
+ double Period, double InstPeriod, double DcPeriod,
+ // Super Smoother state
+ double Mom0, double Mom1, double Filt1, double Filt2,
+ // General
+ int BarCount, double LastValidValue
+ );
+
+ private State _s;
+ private State _ps;
+ private ITValuePublisher? _source;
+ private bool _disposed;
+
+ /// Gets the current estimated dominant cycle period.
+ public double DominantCycle => _s.DcPeriod;
+
+ public override bool IsHot => _s.BarCount >= WarmupPeriod;
+
+ ///
+ /// Creates a new Smoothed Adaptive Momentum indicator.
+ ///
+ /// Smoothing factor for cycle measurement (0 < alpha <= 1). Default 0.07.
+ /// Super Smoother cutoff period (must be >= 2). Default 8.
+ public Sam(double alpha = 0.07, int cutoff = 8)
+ {
+ if (alpha is <= 0 or > 1)
+ {
+ throw new ArgumentException("Alpha must be in (0, 1]", nameof(alpha));
+ }
+ if (cutoff < 2)
+ {
+ throw new ArgumentException("Cutoff must be >= 2", nameof(cutoff));
+ }
+
+ _alpha = alpha;
+ _alphaDecay = 1.0 - alpha;
+
+ // Precompute Super Smoother coefficients
+ double a1 = Math.Exp(-Sqrt2 * Math.PI / cutoff);
+ double b1 = 2.0 * a1 * Math.Cos(Sqrt2 * Math.PI / cutoff);
+ _ssC2 = b1;
+ _ssC3 = -(a1 * a1);
+ _ssC1 = 1.0 - _ssC2 - _ssC3;
+
+ // Price lookback buffer: max dominant cycle period
+ _priceBuf = new RingBuffer(MaxCyclePeriod + 1);
+
+ Name = $"Sam({alpha},{cutoff})";
+ WarmupPeriod = MaxCyclePeriod * 2; // 100 bars for stable cycle detection
+
+ // Initialize state with default period estimate
+ const double initialPeriod = 15.0;
+ _s = new State(
+ 0, 0, 0, 0, // Price history
+ 0, 0, 0, 0, 0, 0, 0, // Smooth price history
+ 0, 0, 0, 0, 0, 0, 0, // Detrender history
+ 0, 0, 0, 0, 0, 0, 0, // I1 history
+ 0, 0, 0, 0, 0, 0, 0, // Q1 history
+ 0, 0, // I2, Q2
+ 0, 0, // Re, Im
+ initialPeriod, initialPeriod, initialPeriod, // Period, InstPeriod, DcPeriod
+ 0, 0, 0, 0, // Mom0, Mom1, Filt1, Filt2
+ 0, 0 // BarCount, LastValidValue
+ );
+ _ps = _s;
+ }
+
+ ///
+ /// Creates a chained Smoothed Adaptive Momentum indicator.
+ ///
+ public Sam(ITValuePublisher source, double alpha = 0.07, int cutoff = 8)
+ : this(alpha, cutoff)
+ {
+ _source = source;
+ _source.Pub += HandleInput;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void HandleInput(object? sender, in TValueEventArgs e)
+ {
+ Update(e.Value, e.IsNew);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ if (isNew)
+ {
+ _ps = _s;
+ }
+ else
+ {
+ _s = _ps;
+ }
+
+ var s = _s;
+
+ // Handle non-finite values
+ double price = input.Value;
+ if (!double.IsFinite(price))
+ {
+ price = s.LastValidValue;
+ }
+ else
+ {
+ s = s with { LastValidValue = price };
+ }
+
+ // Increment bar count
+ int barCount = isNew ? s.BarCount + 1 : s.BarCount;
+
+ // Add price to lookback buffer for adaptive momentum
+ _priceBuf.Add(price, isNew);
+
+ // ── Stage 1: 4-bar FIR smoother: (src + 2*src[1] + 2*src[2] + src[3]) / 6 ──
+ double price3 = s.Price2;
+ double price2 = s.Price1;
+ double price1 = s.Price0;
+ double price0 = price;
+
+ double smoothPrice = (price0 + 2.0 * price1 + 2.0 * price2 + price3) / 6.0;
+
+ // ── Stage 2: Hilbert Transform ──
+ // Adaptive bandwidth based on previous smooth period
+ double bandwidth = 0.075 * s.DcPeriod + 0.54;
+
+ // Shift smooth price history
+ double sp6 = s.Sp5;
+ double sp5 = s.Sp4;
+ double sp4 = s.Sp3;
+ double sp3 = s.Sp2;
+ double sp2 = s.Sp1;
+ double sp1 = s.Sp0;
+ double sp0 = smoothPrice;
+
+ // Detrender: Hilbert Transform of smooth price
+ double detrender = (0.0962 * sp0 + 0.5769 * sp2 - 0.5769 * sp4 - 0.0962 * sp6) * bandwidth;
+
+ // Shift detrender history
+ double det6 = s.Det5;
+ double det5 = s.Det4;
+ double det4 = s.Det3;
+ double det3 = s.Det2;
+ double det2 = s.Det1;
+ double det1 = s.Det0;
+ double det0 = detrender;
+
+ // Q1 via Hilbert Transform of detrender
+ double q1 = (0.0962 * det0 + 0.5769 * det2 - 0.5769 * det4 - 0.0962 * det6) * bandwidth;
+
+ // I1 is detrender delayed by 3 bars
+ double i1 = det3;
+
+ // Shift I1 history for JI calculation
+ double i1_6 = s.I1_5;
+ double i1_5 = s.I1_4;
+ double i1_4 = s.I1_3;
+ double i1_3 = s.I1_2;
+ double i1_2 = s.I1_1;
+ double i1_1 = s.I1_0;
+ double i1_0 = i1;
+
+ // Shift Q1 history for JQ calculation
+ double q1_6 = s.Q1_5;
+ double q1_5 = s.Q1_4;
+ double q1_4 = s.Q1_3;
+ double q1_3 = s.Q1_2;
+ double q1_2 = s.Q1_1;
+ double q1_1 = s.Q1_0;
+ double q1_0 = q1;
+
+ // ── Stage 3: Phase advance ──
+ // JI = Hilbert Transform of I1
+ double ji = (0.0962 * i1_0 + 0.5769 * i1_2 - 0.5769 * i1_4 - 0.0962 * i1_6) * bandwidth;
+
+ // JQ = Hilbert Transform of Q1
+ double jq = (0.0962 * q1_0 + 0.5769 * q1_2 - 0.5769 * q1_4 - 0.0962 * q1_6) * bandwidth;
+
+ // Phasor addition: I2 = I1 - JQ, Q2 = Q1 + JI
+ double i2Raw = i1 - jq;
+ double q2Raw = q1 + ji;
+
+ // EMA smooth I2 and Q2 with configurable alpha
+ double i2 = Math.FusedMultiplyAdd(_alphaDecay, s.I2, _alpha * i2Raw);
+ double q2 = Math.FusedMultiplyAdd(_alphaDecay, s.Q2, _alpha * q2Raw);
+
+ // ── Stage 4: Homodyne Discriminator ──
+ double reRaw = Math.FusedMultiplyAdd(i2, s.I2, q2 * s.Q2);
+ double imRaw = Math.FusedMultiplyAdd(i2, s.Q2, -(q2 * s.I2));
+
+ // EMA smooth Re and Im
+ double re = Math.FusedMultiplyAdd(_alphaDecay, s.Re, _alpha * reRaw);
+ double im = Math.FusedMultiplyAdd(_alphaDecay, s.Im, _alpha * imRaw);
+
+ // Calculate period from phase angle
+ double period = s.Period;
+ if (Math.Abs(im) > 1e-10 && Math.Abs(re) > 1e-10)
+ {
+ double candidate = TwoPi / Math.Atan(im / re);
+ period = Math.Clamp(Math.Abs(candidate), MinCyclePeriod, MaxCyclePeriod);
+ }
+
+ // Double-smoothed dominant cycle period
+ double instPeriod = Math.FusedMultiplyAdd(0.33, period, 0.67 * s.InstPeriod);
+ double dcPeriod = Math.FusedMultiplyAdd(0.15, instPeriod, 0.85 * s.DcPeriod);
+
+ // ── Stage 5: Adaptive momentum ──
+ int dcLen = Math.Max((int)dcPeriod, 1);
+ double momentum;
+ if (_priceBuf.Count > dcLen)
+ {
+ // RingBuffer[0] is oldest; we want price[dcLen] bars ago
+ // Current price is at index (Count-1), price dcLen bars ago is at index (Count-1-dcLen)
+ int lookbackIdx = _priceBuf.Count - 1 - dcLen;
+ momentum = price - _priceBuf[lookbackIdx];
+ }
+ else
+ {
+ momentum = 0.0;
+ }
+
+ // ── Stage 6: 2-pole Super Smoother ──
+ double mom1 = s.Mom0;
+ double mom0 = momentum;
+
+ double filt = _ssC1 * (mom0 + mom1) / 2.0 + _ssC2 * s.Filt1 + _ssC3 * s.Filt2;
+
+ // Update state
+ _s = new State(
+ price0, price1, price2, price3,
+ sp0, sp1, sp2, sp3, sp4, sp5, sp6,
+ det0, det1, det2, det3, det4, det5, det6,
+ i1_0, i1_1, i1_2, i1_3, i1_4, i1_5, i1_6,
+ q1_0, q1_1, q1_2, q1_3, q1_4, q1_5, q1_6,
+ i2, q2,
+ re, im,
+ period, instPeriod, dcPeriod,
+ mom0, mom1, filt, s.Filt1,
+ barCount, s.LastValidValue
+ );
+
+ Last = new TValue(input.Time, filt);
+ 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);
+
+ for (int i = 0; i < len; i++)
+ {
+ var result = Update(source[i]);
+ vSpan[i] = result.Value;
+ }
+ source.Times.CopyTo(tSpan);
+
+ 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;
+ }
+ }
+
+ ///
+ /// Calculates SAM for a time series.
+ ///
+ public static TSeries Batch(TSeries source, double alpha = 0.07, int cutoff = 8)
+ {
+ var sam = new Sam(alpha, cutoff);
+ return sam.Update(source);
+ }
+
+ ///
+ /// Calculates SAM in-place using a pre-allocated output span.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output,
+ double alpha = 0.07, int cutoff = 8)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+
+ if (alpha is <= 0 or > 1)
+ {
+ throw new ArgumentException("Alpha must be in (0, 1]", nameof(alpha));
+ }
+ if (cutoff < 2)
+ {
+ throw new ArgumentException("Cutoff must be >= 2", nameof(cutoff));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ var sam = new Sam(alpha, cutoff);
+ for (int i = 0; i < len; i++)
+ {
+ var result = sam.Update(new TValue(DateTime.UtcNow, source[i]));
+ output[i] = result.Value;
+ }
+ }
+
+ public static (TSeries Results, Sam Indicator) Calculate(TSeries source, double alpha = 0.07, int cutoff = 8)
+ {
+ var indicator = new Sam(alpha, cutoff);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ const double initialPeriod = 15.0;
+ _priceBuf.Clear();
+ _s = new State(
+ 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0,
+ 0, 0,
+ 0, 0,
+ initialPeriod, initialPeriod, initialPeriod,
+ 0, 0, 0, 0,
+ 0, 0
+ );
+ _ps = _s;
+ Last = default;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing && _source != null)
+ {
+ _source.Pub -= HandleInput;
+ _source = null;
+ }
+ _disposed = true;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/numerics/_index.md b/lib/numerics/_index.md
index 3caddd7d..c439d4ca 100644
--- a/lib/numerics/_index.md
+++ b/lib/numerics/_index.md
@@ -7,6 +7,7 @@ Basic mathematical transforms and utility functions for time series. These build
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
| [ACCEL](accel/Accel.md) | Acceleration | Momentum change; second derivative of price. |
+| [AGC](agc/Agc.md) | Ehlers Automatic Gain Control | Amplitude normalization via exponential peak tracking. |
| [CHANGE](change/Change.md) | Percentage Change | Relative price movement over lookback period. |
| [EXPTRANS](exptrans/Exptrans.md) | Exponential Transform | e^x transform for log-space conversion reversal. |
| [HIGHEST](highest/Highest.md) | Rolling Maximum | Maximum value over lookback window. |
diff --git a/lib/filters/agc/Agc.Quantower.Tests.cs b/lib/numerics/agc/Agc.Quantower.Tests.cs
similarity index 100%
rename from lib/filters/agc/Agc.Quantower.Tests.cs
rename to lib/numerics/agc/Agc.Quantower.Tests.cs
diff --git a/lib/filters/agc/Agc.Quantower.cs b/lib/numerics/agc/Agc.Quantower.cs
similarity index 100%
rename from lib/filters/agc/Agc.Quantower.cs
rename to lib/numerics/agc/Agc.Quantower.cs
diff --git a/lib/filters/agc/Agc.Tests.cs b/lib/numerics/agc/Agc.Tests.cs
similarity index 100%
rename from lib/filters/agc/Agc.Tests.cs
rename to lib/numerics/agc/Agc.Tests.cs
diff --git a/lib/filters/agc/Agc.Validation.Tests.cs b/lib/numerics/agc/Agc.Validation.Tests.cs
similarity index 100%
rename from lib/filters/agc/Agc.Validation.Tests.cs
rename to lib/numerics/agc/Agc.Validation.Tests.cs
diff --git a/lib/filters/agc/Agc.cs b/lib/numerics/agc/Agc.cs
similarity index 99%
rename from lib/filters/agc/Agc.cs
rename to lib/numerics/agc/Agc.cs
index 68417b64..b932f969 100644
--- a/lib/filters/agc/Agc.cs
+++ b/lib/numerics/agc/Agc.cs
@@ -10,7 +10,7 @@ namespace QuanTAlib;
///
///
/// The algorithm is based on a Pine Script implementation:
-/// https://github.com/mihakralj/pinescript/blob/main/indicators/filters/agc.md
+/// https://github.com/mihakralj/pinescript/blob/main/indicators/numerics/agc.md
///
/// Key properties:
/// - Pure normalizer: does NOT contain an internal filter stage
diff --git a/lib/filters/agc/Agc.md b/lib/numerics/agc/Agc.md
similarity index 100%
rename from lib/filters/agc/Agc.md
rename to lib/numerics/agc/Agc.md
diff --git a/lib/filters/agc/agc.pine b/lib/numerics/agc/agc.pine
similarity index 100%
rename from lib/filters/agc/agc.pine
rename to lib/numerics/agc/agc.pine
diff --git a/lib/oscillators/_index.md b/lib/oscillators/_index.md
index 8e0fa1b8..56e6432d 100644
--- a/lib/oscillators/_index.md
+++ b/lib/oscillators/_index.md
@@ -22,10 +22,12 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use
| [KRI](kri/Kri.md) | Kairi Relative Index | Percentage deviation of price from SMA. Overbought/oversold. |
| [PGO](pgo/Pgo.md) | Pretty Good Oscillator | Distance from SMA normalized by ATR. Units: ATR multiples. |
| [PSL](psl/Psl.md) | Psychological Line | Ratio of up periods to total periods. Crowd sentiment gauge. |
+| [REFLEX](reflex/Reflex.md) | Ehlers Reflex | Ehlers zero-centered reversal oscillator using super smoother with normalized sum-of-differences. |
| [SMI](smi/Smi.md) | Stochastic Momentum Index | Distance from range midpoint. More sensitive than classic Stochastic. |
| [STOCH](stoch/Stoch.md) | Stochastic Oscillator | Close position within N-period high-low range. Classic overbought/oversold. |
| [STOCHF](stochf/Stochf.md) | Stochastic Fast | Unsmoothed Stochastic. Faster but noisier. |
| [STOCHRSI](stochrsi/Stochrsi.md) | Stochastic RSI | Stochastic applied to RSI. More sensitive than either alone. |
+| [TRENDFLEX](trendflex/Trendflex.md) | Ehlers Trendflex | Ehlers zero-lag trend oscillator using super smoother with sum-of-differences normalization. |
| [TRIX](trix/Trix.md) | Triple Exponential Average | ROC of triple EMA. Filters noise through three smoothings. |
| [TTM_WAVE](ttm_wave/TtmWave.md) | TTM Wave | Fibonacci-period MACD composite (Waves A/B/C). John Carter. |
| [ULTOSC](ultosc/Ultosc.md) | Ultimate Oscillator | Multi-timeframe oscillator. Combines 7, 14, 28 period buying pressure. |
diff --git a/lib/oscillators/bbi/Bbi.md b/lib/oscillators/bbi/Bbi.md
new file mode 100644
index 00000000..0f03b98e
--- /dev/null
+++ b/lib/oscillators/bbi/Bbi.md
@@ -0,0 +1,100 @@
+# BBI: Bulls Bears Index
+
+> "Average four moving averages of doubling periods and you get a single line that votes on whether bulls or bears own the tape. It is a committee of trends, each watching a different time horizon, forced to agree on one number."
+
+BBI (Bulls Bears Index) computes the arithmetic mean of four Simple Moving Averages with geometrically spaced periods (3, 6, 12, 24 by default). The result is a price-overlay line that captures trend consensus across ultra-short, short, medium, and long timeframes simultaneously. Price above BBI signals bullish dominance; price below BBI signals bearish control. The crossover point marks the regime boundary between long and short markets.
+
+## Historical Context
+
+BBI originated in the Chinese stock market technical analysis community, where it became a standard indicator on domestic trading platforms and textbooks. The Chinese name (多空指标, duō kōng zhǐbiāo, literally "long-short indicator") reflects its primary purpose: determining whether the market is in a bullish ("long") or bearish ("short") regime.
+
+The specific period set (3, 6, 12, 24) follows a doubling progression that spans from intraday noise (3 bars) to nearly a full trading month (24 bars on a daily chart). This geometric spacing ensures each SMA captures a distinct frequency band of price behavior. The equal-weight average ($1/4$ each) treats all four timeframes as equally important, which is a deliberate design choice: no single timeframe dominates the composite signal.
+
+BBI is functionally equivalent to a single weighted moving average with a composite kernel. The kernel is the sum of four rectangular windows of lengths 3, 6, 12, and 24, normalized by 4. This means each price bar contributes to the output based on how many of the four SMA windows it falls within: the most recent 3 bars are counted by all four SMAs (effective weight $4/4$), bars 4-6 by three SMAs ($3/4$), bars 7-12 by two ($2/4$), and bars 13-24 by one ($1/4$). The result is a stepped triangular-like kernel that naturally emphasizes recent prices without requiring explicit weight parameters.
+
+## Architecture & Physics
+
+### 1. Four Independent SMA Buffers
+
+Four circular buffers of sizes $N_1, N_2, N_3, N_4$ maintain running sums for O(1) per-bar SMA updates:
+
+$$
+\text{SMA}_k[t] = \frac{1}{N_k} \sum_{i=0}^{N_k - 1} x_{t-i}, \quad k = 1, 2, 3, 4
+$$
+
+### 2. Composite Average
+
+$$
+\text{BBI}[t] = \frac{\text{SMA}_1[t] + \text{SMA}_2[t] + \text{SMA}_3[t] + \text{SMA}_4[t]}{4}
+$$
+
+### 3. Warmup Behavior
+
+Each SMA produces valid output from bar 1 using available data (partial window). The composite BBI is valid from bar 1, with full-window accuracy achieved once all four SMAs have filled: $\text{WarmupPeriod} = \max(N_1, N_2, N_3, N_4) = 24$ bars with default parameters.
+
+## Mathematical Foundation
+
+**Individual SMAs with running sums:**
+
+$$
+S_k[t] = S_k[t-1] - x_{t-N_k} + x_t
+$$
+
+$$
+\text{SMA}_k[t] = \frac{S_k[t]}{N_k}
+$$
+
+**Composite output:**
+
+$$
+\text{BBI}[t] = \frac{1}{4} \sum_{k=1}^{4} \text{SMA}_k[t]
+$$
+
+**Equivalent single-pass kernel:** Substituting the SMA definitions:
+
+$$
+\text{BBI}[t] = \frac{1}{4} \sum_{k=1}^{4} \frac{1}{N_k} \sum_{i=0}^{N_k - 1} x_{t-i} = \sum_{i=0}^{N_4 - 1} w_i \cdot x_{t-i}
+$$
+
+where the effective weight for lag $i$ is:
+
+$$
+w_i = \frac{1}{4} \sum_{k=1}^{4} \frac{\mathbf{1}_{[i < N_k]}}{N_k}
+$$
+
+For default periods $(3, 6, 12, 24)$:
+
+| Lag range | Contributing SMAs | Weight |
+| :--- | :---: | :---: |
+| $0 \leq i < 3$ | All 4 | $\frac{1}{4}\left(\frac{1}{3} + \frac{1}{6} + \frac{1}{12} + \frac{1}{24}\right) \approx 0.1528$ |
+| $3 \leq i < 6$ | SMA2, SMA3, SMA4 | $\frac{1}{4}\left(\frac{1}{6} + \frac{1}{12} + \frac{1}{24}\right) \approx 0.0694$ |
+| $6 \leq i < 12$ | SMA3, SMA4 | $\frac{1}{4}\left(\frac{1}{12} + \frac{1}{24}\right) \approx 0.0313$ |
+| $12 \leq i < 24$ | SMA4 only | $\frac{1}{4} \cdot \frac{1}{24} \approx 0.0104$ |
+
+**Group delay:** The weighted centroid of the composite kernel determines the effective lag:
+
+$$
+\bar{d} = \frac{1}{4} \sum_{k=1}^{4} \frac{N_k - 1}{2} = \frac{1}{4} \cdot \frac{(3-1) + (6-1) + (12-1) + (24-1)}{2} = \frac{42}{8} = 5.25 \text{ bars}
+$$
+
+**Default parameters:** `p1 = 3`, `p2 = 6`, `p3 = 12`, `p4 = 24`, `minPeriod = 1`.
+
+**Pseudo-code (streaming):**
+
+```
+// Four circular buffers with running sums
+for k = 1 to 4:
+ sum[k] -= buf[k][head[k]]
+ sum[k] += src
+ buf[k][head[k]] = src
+ head[k] = (head[k] + 1) % period[k]
+ sma[k] = sum[k] / min(count, period[k])
+
+return (sma[1] + sma[2] + sma[3] + sma[4]) / 4
+```
+
+## Resources
+
+- TradingView. "BBI - Bull and Bear Index." Community Scripts. (Standard implementation reference.)
+- Chinese Securities Association. Technical analysis indicator specifications. (Origin of 3/6/12/24 period convention.)
+- Binance Square. "BBI Indicator Usage Tutorial." (Modern application to cryptocurrency markets.)
diff --git a/lib/filters/reflex/Reflex.md b/lib/oscillators/reflex/Reflex.md
similarity index 100%
rename from lib/filters/reflex/Reflex.md
rename to lib/oscillators/reflex/Reflex.md
diff --git a/lib/filters/reflex/reflex.pine b/lib/oscillators/reflex/reflex.pine
similarity index 100%
rename from lib/filters/reflex/reflex.pine
rename to lib/oscillators/reflex/reflex.pine
diff --git a/lib/trends_IIR/trendflex/Trendflex.Quantower.Tests.cs b/lib/oscillators/trendflex/Trendflex.Quantower.Tests.cs
similarity index 100%
rename from lib/trends_IIR/trendflex/Trendflex.Quantower.Tests.cs
rename to lib/oscillators/trendflex/Trendflex.Quantower.Tests.cs
diff --git a/lib/trends_IIR/trendflex/Trendflex.Quantower.cs b/lib/oscillators/trendflex/Trendflex.Quantower.cs
similarity index 93%
rename from lib/trends_IIR/trendflex/Trendflex.Quantower.cs
rename to lib/oscillators/trendflex/Trendflex.Quantower.cs
index 18fcb78e..ac7068d4 100644
--- a/lib/trends_IIR/trendflex/Trendflex.Quantower.cs
+++ b/lib/oscillators/trendflex/Trendflex.Quantower.cs
@@ -25,7 +25,7 @@ public sealed class TrendflexIndicator : Indicator, IWatchlistIndicator
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"TRENDFLEX {Period}:{_sourceName}";
- public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/trendflex/Trendflex.Quantower.cs";
+public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/trendflex/Trendflex.Quantower.cs";
public TrendflexIndicator()
{
diff --git a/lib/trends_IIR/trendflex/Trendflex.Tests.cs b/lib/oscillators/trendflex/Trendflex.Tests.cs
similarity index 100%
rename from lib/trends_IIR/trendflex/Trendflex.Tests.cs
rename to lib/oscillators/trendflex/Trendflex.Tests.cs
diff --git a/lib/trends_IIR/trendflex/Trendflex.Validation.Tests.cs b/lib/oscillators/trendflex/Trendflex.Validation.Tests.cs
similarity index 100%
rename from lib/trends_IIR/trendflex/Trendflex.Validation.Tests.cs
rename to lib/oscillators/trendflex/Trendflex.Validation.Tests.cs
diff --git a/lib/trends_IIR/trendflex/Trendflex.cs b/lib/oscillators/trendflex/Trendflex.cs
similarity index 100%
rename from lib/trends_IIR/trendflex/Trendflex.cs
rename to lib/oscillators/trendflex/Trendflex.cs
diff --git a/lib/trends_IIR/trendflex/Trendflex.md b/lib/oscillators/trendflex/Trendflex.md
similarity index 100%
rename from lib/trends_IIR/trendflex/Trendflex.md
rename to lib/oscillators/trendflex/Trendflex.md
diff --git a/lib/trends_IIR/trendflex/trendflex.pine b/lib/oscillators/trendflex/trendflex.pine
similarity index 100%
rename from lib/trends_IIR/trendflex/trendflex.pine
rename to lib/oscillators/trendflex/trendflex.pine
diff --git a/lib/trends_FIR/_index.md b/lib/trends_FIR/_index.md
index bb9092f0..2ecb9448 100644
--- a/lib/trends_FIR/_index.md
+++ b/lib/trends_FIR/_index.md
@@ -17,9 +17,12 @@ Finite Impulse Response (FIR) trend indicators. These use fixed-length windows w
| [HAMMA](hamma/Hamma.md) | Hamming MA | Hamming window. -43 dB side lobes. Good general purpose. |
| [HANMA](hanma/Hanma.md) | Hanning MA | Hanning (raised cosine). Zero at edges. Smooth roll-off. |
| [HMA](hma/Hma.md) | Hull MA | Reduced lag via weighted average differencing. Can overshoot. |
-| [HWMA](hwma/Hwma.md) | Holt-Winters MA | Triple exponential smoothing. Tracks level, velocity, acceleration. |
| [LSMA](lsma/Lsma.md) | Least Squares MA | Linear regression endpoint. Extrapolates trend. |
+| [NLMA](nlma/Nlma.md) | Non-Lag MA | Damped cosine kernel convolution. Near-zero lag FIR. |
+| [NYQMA](nyqma/Nyqma.md) | Nyquist MA | Dual LWMA cascade. Nyquist-compliant FIR smoothing. |
+| [PMA](pma/Pma.md) | Predictive Moving Average | Ehlers predictive filter combining WMA cascade with linear extrapolation. |
| [PWMA](pwma/Pwma.md) | Pascal Weighted MA | Pascal's triangle coefficients. Binomial distribution weights. |
+| [RAIN](rain/Rain.md) | Rainbow MA | 10× cascaded SMA. Extreme smoothing via FIR convolution. |
| [SGMA](sgma/Sgma.md) | Savitzky-Golay MA | Polynomial fit. Preserves higher moments. Shape-preserving. |
| [SINEMA](sinema/Sinema.md) | Sine-Weighted MA | Sine wave weighting. Smooth bell-shaped emphasis. |
| [SMA](sma/Sma.md) | Simple MA | Equal weights. Baseline reference. Lag = (N-1)/2. |
diff --git a/lib/trends_FIR/crma/Crma.Quantower.Tests.cs b/lib/trends_FIR/crma/Crma.Quantower.Tests.cs
new file mode 100644
index 00000000..4a8c1f47
--- /dev/null
+++ b/lib/trends_FIR/crma/Crma.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class CrmaIndicatorTests
+{
+ [Fact]
+ public void CrmaIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new CrmaIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("CRMA - Cubic Regression Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void CrmaIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new CrmaIndicator { Period = 20 };
+
+ Assert.Equal(0, CrmaIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void CrmaIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new CrmaIndicator { Period = 15 };
+
+ Assert.Contains("CRMA", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CrmaIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new CrmaIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Crma.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CrmaIndicator_Initialize_CreatesInternalCrma()
+ {
+ var indicator = new CrmaIndicator { Period = 10 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void CrmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new CrmaIndicator { Period = 4 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void CrmaIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new CrmaIndicator { Period = 4 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void CrmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new CrmaIndicator { Period = 4 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void CrmaIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new CrmaIndicator { Period = 4 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void CrmaIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new CrmaIndicator { Period = 4, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void CrmaIndicator_Period_CanBeChanged()
+ {
+ var indicator = new CrmaIndicator { Period = 5 };
+ Assert.Equal(5, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, CrmaIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/crma/Crma.Quantower.cs b/lib/trends_FIR/crma/Crma.Quantower.cs
new file mode 100644
index 00000000..fb634bdb
--- /dev/null
+++ b/lib/trends_FIR/crma/Crma.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class CrmaIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 4, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Crma _crma = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"CRMA {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/crma/Crma.Quantower.cs";
+
+ public CrmaIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "CRMA - Cubic Regression Moving Average";
+ Description = "Cubic Regression Moving Average";
+ _series = new LineSeries(name: $"CRMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _crma = new Crma(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _crma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _crma.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/crma/Crma.Tests.cs b/lib/trends_FIR/crma/Crma.Tests.cs
new file mode 100644
index 00000000..32a99e61
--- /dev/null
+++ b/lib/trends_FIR/crma/Crma.Tests.cs
@@ -0,0 +1,461 @@
+namespace QuanTAlib.Tests;
+
+public class CrmaTests
+{
+ [Fact]
+ public void Constructor_InvalidPeriod_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new Crma(0));
+ Assert.Throws(() => new Crma(-1));
+ Assert.Throws(() => new Crma(3)); // Minimum is 4
+ }
+
+ [Fact]
+ public void Constructor_ValidParameters_SetsProperties()
+ {
+ var crma = new Crma(14);
+ Assert.Equal("Crma(14)", crma.Name);
+ Assert.False(crma.IsHot);
+ }
+
+ [Fact]
+ public void Update_SingleValue_ReturnsSameValue()
+ {
+ var crma = new Crma(14);
+ var result = crma.Update(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, result.Value);
+ }
+
+ [Fact]
+ public void Update_LinearTrend_ReturnsExactValue()
+ {
+ // For a perfect linear trend y = x, cubic regression should also return x
+ // (higher-order coefficients become zero)
+ const int period = 10;
+ var crma = new Crma(period);
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ var result = crma.Update(new TValue(DateTime.UtcNow, i));
+ if (i >= period) // After warmup
+ {
+ Assert.Equal(i, result.Value, 1e-6);
+ }
+ }
+ }
+
+ [Fact]
+ public void Update_QuadraticTrend_ReturnsExactValue()
+ {
+ // For y = x², cubic regression should fit exactly
+ const int period = 10;
+ var crma = new Crma(period);
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ double y = (double)i * i;
+ var result = crma.Update(new TValue(DateTime.UtcNow, y));
+ if (i >= period)
+ {
+ Assert.Equal(y, result.Value, 1e-4);
+ }
+ }
+ }
+
+ [Fact]
+ public void Update_CubicTrend_ReturnsExactValue()
+ {
+ // For y = x³, cubic regression should fit exactly
+ const int period = 10;
+ var crma = new Crma(period);
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ double y = (double)i * i * i;
+ var result = crma.Update(new TValue(DateTime.UtcNow, y));
+ if (i >= period)
+ {
+ Assert.Equal(y, result.Value, 1e-1);
+ }
+ }
+ }
+
+ [Fact]
+ public void Update_ConstantValue_ReturnsSameValue()
+ {
+ const int period = 10;
+ var crma = new Crma(period);
+ const double value = 123.45;
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ var result = crma.Update(new TValue(DateTime.UtcNow, value));
+ Assert.Equal(value, result.Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Update_BarCorrection_UpdatesCorrectly()
+ {
+ var crma = new Crma(5);
+
+ // Fill buffer
+ for (int i = 0; i < 5; i++)
+ {
+ crma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ // New bar
+ var result1 = crma.Update(new TValue(DateTime.UtcNow, 10));
+
+ // Update same bar with different value
+ var result2 = crma.Update(new TValue(DateTime.UtcNow, 20), isNew: false);
+
+ Assert.NotEqual(result1.Value, result2.Value);
+
+ // Verify internal state by adding next bar
+ var result3 = crma.Update(new TValue(DateTime.UtcNow, 30));
+ Assert.True(double.IsFinite(result3.Value));
+ }
+
+ [Fact]
+ public void Update_IterativeCorrection_RestoresState()
+ {
+ var crma = new Crma(5);
+
+ // Build up state
+ for (int i = 0; i < 10; i++)
+ {
+ crma.Update(new TValue(DateTime.UtcNow, i * 10.0));
+ }
+
+ // New bar
+ var resultNew = crma.Update(new TValue(DateTime.UtcNow, 100));
+
+ // Multiple corrections on the same bar
+ crma.Update(new TValue(DateTime.UtcNow, 105), isNew: false);
+ crma.Update(new TValue(DateTime.UtcNow, 110), isNew: false);
+ var resultFinal = crma.Update(new TValue(DateTime.UtcNow, 100), isNew: false);
+
+ // Correcting back to original value should give same result
+ Assert.Equal(resultNew.Value, resultFinal.Value, 1e-9);
+ }
+
+ [Fact]
+ public void Update_NaN_HandlesGracefully()
+ {
+ var crma = new Crma(5);
+
+ for (int i = 1; i <= 5; i++)
+ {
+ crma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ // NaN should be replaced with last valid value
+ var result = crma.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_Infinity_HandlesGracefully()
+ {
+ var crma = new Crma(5);
+
+ for (int i = 1; i <= 5; i++)
+ {
+ crma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ var result = crma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_BatchNaN_Safe()
+ {
+ var crma = new Crma(5);
+ crma.Update(new TValue(DateTime.UtcNow, 10));
+
+ // Several NaN values
+ for (int i = 0; i < 5; i++)
+ {
+ var result = crma.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(result.Value));
+ }
+ }
+
+ [Fact]
+ public void Calculate_StaticMethod_MatchesObjectInstance()
+ {
+ const int period = 10;
+ const int count = 100;
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ var crma = new Crma(period);
+ var series1 = crma.Update(source);
+ var series2 = Crma.Batch(source, period);
+
+ Assert.Equal(series1.Count, series2.Count);
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(series1[i].Value, series2[i].Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Calculate_Span_MatchesSeries()
+ {
+ const int period = 10;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ values[i] = bar.Close;
+ }
+
+ Crma.Batch(values, output, period);
+
+ var crma = new Crma(period);
+ for (int i = 0; i < count; i++)
+ {
+ var result = crma.Update(new TValue(DateTime.UtcNow, values[i]));
+ Assert.Equal(result.Value, output[i], 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Span_InvalidLength_ThrowsArgumentException()
+ {
+ var source = new double[10];
+ var output = new double[5]; // Mismatched length
+
+ var ex = Assert.Throws(() => Crma.Batch(source, output, 4));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Span_InvalidPeriod_ThrowsArgumentException()
+ {
+ var source = new double[10];
+ var output = new double[10];
+
+ var ex = Assert.Throws(() => Crma.Batch(source, output, 3));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Span_LargeData_DoesNotStackOverflow()
+ {
+ const int period = 20;
+ const int count = 5000;
+ var values = new double[count];
+ var output = new double[count];
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = gbm.Next().Close;
+ }
+
+ // Should not throw
+ Crma.Batch(values, output, period);
+
+ // All post-warmup values should be finite
+ for (int i = period; i < count; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
+ }
+ }
+
+ [Fact]
+ public void Span_NaN_HandledCorrectly()
+ {
+ const int period = 5;
+ var source = new double[] { 1, 2, 3, double.NaN, 5, 6, 7, 8, 9, 10 };
+ var output = new double[source.Length];
+
+ Crma.Batch(source, output, period);
+
+ for (int i = 0; i < source.Length; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
+ }
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var crma = new Crma(5);
+ for (int i = 0; i < 10; i++)
+ {
+ crma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ Assert.True(crma.IsHot);
+
+ crma.Reset();
+
+ Assert.False(crma.IsHot);
+ Assert.Equal(0, crma.Last.Value);
+
+ var result = crma.Update(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, result.Value);
+ }
+
+ [Fact]
+ public void IsHot_BecomesTrueWhenBufferFull()
+ {
+ const int period = 5;
+ var crma = new Crma(period);
+
+ for (int i = 0; i < period; i++)
+ {
+ Assert.False(crma.IsHot);
+ crma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ Assert.True(crma.IsHot);
+ }
+
+ [Fact]
+ public void Chainability_Works()
+ {
+ var source = new TSeries();
+ var crma = new Crma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, crma.Last.Value);
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var crma = new Crma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, crma.Last.Value);
+
+ crma.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, crma.Last.Value); // Should remain at previous value
+ }
+
+ [Fact]
+ public void Dispose_IsIdempotent()
+ {
+ var source = new TSeries();
+ var crma = new Crma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+
+#pragma warning disable S3966
+ crma.Dispose();
+ crma.Dispose();
+#pragma warning restore S3966
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, crma.Last.Value);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task Dispose_IsThreadSafe()
+ {
+ var source = new TSeries();
+ var crma = new Crma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+
+ var tasks = new System.Threading.Tasks.Task[10];
+ for (int i = 0; i < tasks.Length; i++)
+ {
+ tasks[i] = System.Threading.Tasks.Task.Run(() => crma.Dispose());
+ }
+
+ await System.Threading.Tasks.Task.WhenAll(tasks);
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, crma.Last.Value);
+ }
+
+ [Fact]
+ public void Dispose_WithoutSource_DoesNotThrow()
+ {
+ var crma = new Crma(5);
+
+#pragma warning disable S3966
+ crma.Dispose();
+ crma.Dispose();
+#pragma warning restore S3966
+
+ Assert.False(crma.IsHot);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new Crma(null!, 5));
+ }
+
+ [Fact]
+ public void AllModes_ProduceConsistentResults()
+ {
+ const int period = 10;
+ const int count = 50;
+ var gbm = new GBM(startPrice: 100, seed: 42);
+ var source = new TSeries();
+ var values = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ values[i] = bar.Close;
+ }
+
+ // Mode 1: Streaming
+ var streaming = new Crma(period);
+ var streamingResults = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ streamingResults[i] = streaming.Update(source[i]).Value;
+ }
+
+ // Mode 2: Batch TSeries
+ var batchResults = Crma.Batch(source, period);
+
+ // Mode 3: Span
+ var spanOutput = new double[count];
+ Crma.Batch(values, spanOutput, period);
+
+ // Mode 4: Event-based
+ var eventSource = new TSeries();
+ var eventCrma = new Crma(eventSource, period);
+ var eventResults = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ eventSource.Add(source[i]);
+ eventResults[i] = eventCrma.Last.Value;
+ }
+
+ // All four modes should match
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(streamingResults[i], batchResults[i].Value, 1e-9);
+ Assert.Equal(streamingResults[i], spanOutput[i], 1e-9);
+ Assert.Equal(streamingResults[i], eventResults[i], 1e-9);
+ }
+ }
+}
diff --git a/lib/trends_FIR/crma/Crma.Validation.Tests.cs b/lib/trends_FIR/crma/Crma.Validation.Tests.cs
new file mode 100644
index 00000000..d06f4341
--- /dev/null
+++ b/lib/trends_FIR/crma/Crma.Validation.Tests.cs
@@ -0,0 +1,167 @@
+using Xunit.Abstractions;
+
+namespace QuanTAlib.Tests;
+
+public class CrmaValidationTests
+{
+ private readonly ValidationTestData _testData;
+ private readonly ITestOutputHelper _output;
+
+ public CrmaValidationTests(ITestOutputHelper output)
+ {
+ _output = output;
+ _testData = new ValidationTestData();
+ }
+
+ [Fact]
+ public void Validate_Batch_Vs_Streaming()
+ {
+ int[] periods = { 5, 10, 14, 20, 50 };
+
+ foreach (var period in periods)
+ {
+ // Calculate QuanTAlib CRMA (batch TSeries)
+ var crma = new global::QuanTAlib.Crma(period);
+ var batchResult = crma.Update(_testData.Data);
+
+ // Calculate QuanTAlib CRMA (streaming)
+ var crmaStreaming = new global::QuanTAlib.Crma(period);
+ var streamingResults = new List();
+ foreach (var item in _testData.Data)
+ {
+ streamingResults.Add(crmaStreaming.Update(item).Value);
+ }
+
+ // Compare all records
+ Assert.Equal(batchResult.Count, streamingResults.Count);
+ for (int i = 0; i < batchResult.Count; i++)
+ {
+ Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-9);
+ }
+ }
+ _output.WriteLine("CRMA Batch(TSeries) vs Streaming validated successfully");
+ }
+
+ [Fact]
+ public void Validate_Span_Vs_Streaming()
+ {
+ int[] periods = { 5, 10, 14, 20, 50 };
+
+ foreach (var period in periods)
+ {
+ // Calculate QuanTAlib CRMA (Span API)
+ double[] qOutput = new double[_testData.RawData.Length];
+ global::QuanTAlib.Crma.Batch(_testData.RawData.Span, qOutput.AsSpan(), period);
+
+ // Calculate QuanTAlib CRMA (streaming)
+ var crmaStreaming = new global::QuanTAlib.Crma(period);
+ var streamingResults = new List();
+ foreach (var item in _testData.Data)
+ {
+ streamingResults.Add(crmaStreaming.Update(item).Value);
+ }
+
+ // Compare all records
+ for (int i = 0; i < qOutput.Length; i++)
+ {
+ Assert.Equal(streamingResults[i], qOutput[i], 1e-9);
+ }
+ }
+ _output.WriteLine("CRMA Span vs Streaming validated successfully");
+ }
+
+ [Fact]
+ public void Validate_Calculate_ReturnsHotIndicator()
+ {
+ int[] periods = { 5, 10, 14, 20 };
+
+ foreach (var period in periods)
+ {
+ var (results, indicator) = global::QuanTAlib.Crma.Calculate(_testData.Data, period);
+
+ Assert.True(indicator.IsHot);
+ Assert.Equal(results.Count, _testData.Data.Count);
+ Assert.True(double.IsFinite(indicator.Last.Value));
+
+ // The hot indicator should continue to produce valid results
+ var nextResult = indicator.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(nextResult.Value));
+ }
+ _output.WriteLine("CRMA Calculate returns hot indicator validated successfully");
+ }
+
+ [Fact]
+ public void Validate_LinearData_ExactFit()
+ {
+ // For linear data y = 2x + 5, cubic regression should fit exactly
+ const int period = 14;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = 2.0 * i + 5.0;
+ }
+
+ global::QuanTAlib.Crma.Batch(values, output, period);
+
+ // After warmup, should match perfectly (linear is subset of cubic)
+ // Numerical precision degrades with large power sums (x^6), so use 1e-3
+ for (int i = period; i < count; i++)
+ {
+ Assert.Equal(values[i], output[i], 1e-3);
+ }
+ _output.WriteLine("CRMA linear data exact fit validated successfully");
+ }
+
+ [Fact]
+ public void Validate_QuadraticData_ExactFit()
+ {
+ // For quadratic data y = 0.5x² + x + 3, cubic regression should fit exactly
+ const int period = 14;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = 0.5 * i * i + i + 3.0;
+ }
+
+ global::QuanTAlib.Crma.Batch(values, output, period);
+
+ // After warmup, should match well (quadratic is subset of cubic)
+ // Large x^6 power sums cause numerical conditioning issues
+ for (int i = period; i < count; i++)
+ {
+ Assert.Equal(values[i], output[i], 1.0);
+ }
+ _output.WriteLine("CRMA quadratic data exact fit validated successfully");
+ }
+
+ [Fact]
+ public void Validate_CubicData_ExactFit()
+ {
+ // For cubic data y = 0.001x³ + 0.01x² + x + 5, should fit exactly
+ // Use small coefficients to reduce numerical conditioning issues
+ const int period = 10;
+ const int count = 30;
+ var values = new double[count];
+ var output = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = 0.001 * i * i * i + 0.01 * i * i + i + 5.0;
+ }
+
+ global::QuanTAlib.Crma.Batch(values, output, period);
+
+ // Cubic data within a cubic model should fit well but with numerical noise
+ for (int i = period; i < count; i++)
+ {
+ Assert.Equal(values[i], output[i], 1.0);
+ }
+ _output.WriteLine("CRMA cubic data exact fit validated successfully");
+ }
+}
diff --git a/lib/trends_FIR/crma/Crma.cs b/lib/trends_FIR/crma/Crma.cs
new file mode 100644
index 00000000..e8119075
--- /dev/null
+++ b/lib/trends_FIR/crma/Crma.cs
@@ -0,0 +1,431 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// CRMA: Cubic Regression Moving Average
+///
+///
+/// Fits a degree-3 polynomial y = a0 + a1*x + a2*x² + a3*x³ to the most recent
+/// N bars via least squares, returns the fitted endpoint value a0.
+///
+/// Calculation: Accumulate 7 power sums + 4 cross-products in O(N), solve 4×4
+/// normal equations via Gaussian elimination with partial pivoting in O(1).
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Crma : AbstractBase
+{
+ private readonly int _period;
+ private readonly RingBuffer _buffer;
+ private readonly TValuePublishedHandler _handler;
+ private ITValuePublisher? _source;
+ private int _disposed;
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(double LastVal, double LastValidValue);
+ private State _state;
+ private State _p_state;
+
+ private bool _isNew;
+
+ public override bool IsHot => _buffer.IsFull;
+ public bool IsNew => _isNew;
+
+ ///
+ /// Creates CRMA with specified period.
+ ///
+ /// Lookback period (must be >= 4 for cubic regression)
+ public Crma(int period)
+ {
+ if (period < 4)
+ {
+ throw new ArgumentException("Period must be at least 4 for cubic regression", nameof(period));
+ }
+
+ _period = period;
+ _buffer = new RingBuffer(period);
+ Name = $"Crma({period})";
+ WarmupPeriod = period;
+ _handler = Handle;
+ _state.LastValidValue = double.NaN;
+ }
+
+ public Crma(ITValuePublisher source, int period) : this(period)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ _source.Pub += _handler;
+ }
+
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ _state.LastValidValue = input;
+ return input;
+ }
+ return _state.LastValidValue;
+ }
+
+ ///
+ /// Solves the 4×4 normal equation system for cubic polynomial regression.
+ /// Returns the intercept a0 (fitted value at x=0, the newest bar).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double SolveCubic(ReadOnlySpan data, int count)
+ {
+ // Accumulate power sums S0..S6 and cross-products r0..r3
+ double s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0, s6 = 0;
+ double r0 = 0, r1 = 0, r2 = 0, r3 = 0;
+
+ for (int i = 0; i < count; i++)
+ {
+ double v = data[i];
+ double x = (double)i;
+ double x2 = x * x;
+ double x3 = x2 * x;
+
+ s0 += 1.0;
+ s1 += x;
+ s2 += x2;
+ s3 += x3;
+ s4 += x2 * x2;
+ s5 += x2 * x3;
+ s6 += x3 * x3;
+
+ r0 += v;
+ r1 = Math.FusedMultiplyAdd(x, v, r1);
+ r2 = Math.FusedMultiplyAdd(x2, v, r2);
+ r3 = Math.FusedMultiplyAdd(x3, v, r3);
+ }
+
+ // Build 4×5 augmented matrix (row-major, inline on stack)
+ // [s0 s1 s2 s3 | r0]
+ // [s1 s2 s3 s4 | r1]
+ // [s2 s3 s4 s5 | r2]
+ // [s3 s4 s5 s6 | r3]
+ Span m = stackalloc double[20];
+ m[0] = s0; m[1] = s1; m[2] = s2; m[3] = s3; m[4] = r0;
+ m[5] = s1; m[6] = s2; m[7] = s3; m[8] = s4; m[9] = r1;
+ m[10] = s2; m[11] = s3; m[12] = s4; m[13] = s5; m[14] = r2;
+ m[15] = s3; m[16] = s4; m[17] = s5; m[18] = s6; m[19] = r3;
+
+ // Gaussian elimination with partial pivoting
+ for (int col = 0; col < 4; col++)
+ {
+ // Find pivot row
+ int pivotRow = col;
+ double pivotMax = Math.Abs(m[col * 5 + col]);
+ for (int row = col + 1; row < 4; row++)
+ {
+ double absVal = Math.Abs(m[row * 5 + col]);
+ if (absVal > pivotMax)
+ {
+ pivotMax = absVal;
+ pivotRow = row;
+ }
+ }
+
+ if (pivotMax < 1e-12)
+ {
+ return double.NaN; // Singular — caller will substitute raw price
+ }
+
+ // Swap rows if needed
+ if (pivotRow != col)
+ {
+ int colOff = col * 5;
+ int pivOff = pivotRow * 5;
+ for (int k = col; k < 5; k++)
+ {
+ (m[colOff + k], m[pivOff + k]) = (m[pivOff + k], m[colOff + k]);
+ }
+ }
+
+ // Eliminate below
+ double diag = m[col * 5 + col];
+ for (int row = col + 1; row < 4; row++)
+ {
+ double factor = m[row * 5 + col] / diag;
+ for (int k = col; k < 5; k++)
+ {
+ m[row * 5 + k] = Math.FusedMultiplyAdd(-factor, m[col * 5 + k], m[row * 5 + k]);
+ }
+ }
+ }
+
+ // Back-substitution
+ Span a = stackalloc double[4];
+ for (int row = 3; row >= 0; row--)
+ {
+ double val = m[row * 5 + 4];
+ for (int k = row + 1; k < 4; k++)
+ {
+ val = Math.FusedMultiplyAdd(-m[row * 5 + k], a[k], val);
+ }
+ a[row] = val / m[row * 5 + row];
+ }
+
+ return a[0]; // Fitted value at x=0 (newest bar)
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ if (isNew)
+ {
+ _p_state = _state;
+ double val = GetValidValue(input.Value);
+ _buffer.Add(val);
+ _state.LastVal = val;
+ }
+ else
+ {
+ _state.LastValidValue = _p_state.LastValidValue;
+ double val = GetValidValue(input.Value);
+ _buffer.UpdateNewest(val);
+ _state.LastVal = val;
+ }
+
+ double result;
+ int count = _buffer.Count;
+ if (count < 4)
+ {
+ // Not enough points for cubic regression — return current value
+ result = _buffer.Newest;
+ }
+ else
+ {
+ // Get buffer data in chronological order (oldest=index 0, newest=last)
+ // We need newest at x=0, so we reverse the iteration in SolveCubic
+ // Actually, we pass data newest-first: data[0]=newest, data[count-1]=oldest
+ // This matches the PineScript convention: x=0 for newest
+ const int StackAllocThreshold = 256;
+ double[]? rented = count > StackAllocThreshold ? ArrayPool.Shared.Rent(count) : null;
+ Span data = rented != null
+ ? rented.AsSpan(0, count)
+ : stackalloc double[count];
+
+ try
+ {
+ // Copy buffer in reverse chronological order (newest first)
+ var span = _buffer.GetSpan();
+ for (int i = 0; i < count; i++)
+ {
+ data[i] = span[count - 1 - i];
+ }
+
+ double solved = SolveCubic(data, count);
+ result = double.IsFinite(solved) ? solved : _buffer.Newest;
+ }
+ finally
+ {
+ if (rented != null)
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+ }
+
+ Last = new TValue(input.Time, result);
+ PubEvent(Last, isNew);
+ return Last;
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ double initialLastValid = _state.LastValidValue;
+ Batch(source.Values, vSpan, _period, initialLastValid);
+ source.Times.CopyTo(tSpan);
+
+ // Restore state by replaying last 'period' bars
+ int windowSize = Math.Min(len, _period);
+ int startIndex = len - windowSize;
+
+ Reset();
+
+ if (startIndex > 0)
+ {
+ for (int i = startIndex - 1; i >= 0; i--)
+ {
+ if (double.IsFinite(source.Values[i]))
+ {
+ _state.LastValidValue = source.Values[i];
+ break;
+ }
+ }
+ }
+ else
+ {
+ _state.LastValidValue = initialLastValid;
+ }
+
+ for (int i = startIndex; i < len; i++)
+ {
+ double val = GetValidValue(source.Values[i]);
+ _buffer.Add(val);
+ _state.LastVal = val;
+ }
+ _p_state = _state;
+
+ Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
+ return new TSeries(t, v);
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ public static TSeries Batch(TSeries source, int period)
+ {
+ var crma = new Crma(period);
+ return crma.Update(source);
+ }
+
+ ///
+ /// Calculates CRMA in-place, writing results to pre-allocated output span.
+ /// Zero-allocation method for maximum performance.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period, double initialLastValid = double.NaN)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+
+ if (period < 4)
+ {
+ throw new ArgumentException("Period must be at least 4 for cubic regression", nameof(period));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ const int StackAllocThreshold = 256;
+
+ // Pre-process: build a NaN-corrected copy of source so we can index it directly
+ double[]? rentedClean = len > StackAllocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = rentedClean != null
+ ? rentedClean.AsSpan(0, len)
+ : stackalloc double[len];
+
+ double[]? rentedData = period > StackAllocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span dataBuffer = rentedData != null
+ ? rentedData.AsSpan(0, period)
+ : stackalloc double[period];
+
+ try
+ {
+ double lastValid = initialLastValid;
+
+ // Build NaN-corrected array
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else
+ {
+ clean[i] = lastValid;
+ }
+ }
+
+ // For each bar, solve cubic regression over the window
+ for (int i = 0; i < len; i++)
+ {
+ int n = Math.Min(i + 1, period);
+ if (n < 4)
+ {
+ output[i] = clean[i];
+ }
+ else
+ {
+ // Build newest-first data for SolveCubic
+ Span data = dataBuffer[..n];
+ for (int j = 0; j < n; j++)
+ {
+ data[j] = clean[i - j]; // newest first (data[0]=bar i, data[1]=bar i-1, ...)
+ }
+
+ double solved = SolveCubic(data, n);
+ output[i] = double.IsFinite(solved) ? solved : clean[i];
+ }
+ }
+ }
+ finally
+ {
+ if (rentedClean != null)
+ {
+ ArrayPool.Shared.Return(rentedClean);
+ }
+ if (rentedData != null)
+ {
+ ArrayPool.Shared.Return(rentedData);
+ }
+ }
+ }
+
+ public static (TSeries Results, Crma Indicator) Calculate(TSeries source, int period)
+ {
+ var indicator = new Crma(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ ///
+ /// Resets the CRMA state.
+ ///
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _state = default;
+ _state.LastValidValue = double.NaN;
+ _p_state = default;
+ Last = default;
+ }
+
+ ///
+ /// Disposes the Crma instance, unsubscribing from the source publisher if subscribed.
+ /// This method is idempotent and thread-safe.
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null)
+ {
+ _source.Pub -= _handler;
+ _source = null;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_FIR/hend/Hend.Quantower.Tests.cs b/lib/trends_FIR/hend/Hend.Quantower.Tests.cs
new file mode 100644
index 00000000..8fe43984
--- /dev/null
+++ b/lib/trends_FIR/hend/Hend.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class HendIndicatorTests
+{
+ [Fact]
+ public void HendIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new HendIndicator();
+
+ Assert.Equal(7, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("HEND - Henderson Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void HendIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new HendIndicator { Period = 13 };
+
+ Assert.Equal(0, HendIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void HendIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new HendIndicator { Period = 9 };
+
+ Assert.Contains("HEND", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("9", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void HendIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new HendIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Hend.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void HendIndicator_Initialize_CreatesInternalHend()
+ {
+ var indicator = new HendIndicator { Period = 7 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void HendIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new HendIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void HendIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new HendIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void HendIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new HendIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void HendIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new HendIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void HendIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new HendIndicator { Period = 5, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void HendIndicator_Period_CanBeChanged()
+ {
+ var indicator = new HendIndicator { Period = 7 };
+ Assert.Equal(7, indicator.Period);
+
+ indicator.Period = 13;
+ Assert.Equal(13, indicator.Period);
+ Assert.Equal(0, HendIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/hend/Hend.Quantower.cs b/lib/trends_FIR/hend/Hend.Quantower.cs
new file mode 100644
index 00000000..428b5def
--- /dev/null
+++ b/lib/trends_FIR/hend/Hend.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class HendIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 5, 2000, 2, 0)]
+ public int Period { get; set; } = 7;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Hend _hend = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"HEND {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/hend/Hend.Quantower.cs";
+
+ public HendIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "HEND - Henderson Moving Average";
+ Description = "Henderson Moving Average";
+ _series = new LineSeries(name: $"HEND {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _hend = new Hend(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _hend.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _hend.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/hend/Hend.Tests.cs b/lib/trends_FIR/hend/Hend.Tests.cs
new file mode 100644
index 00000000..7ac9f21b
--- /dev/null
+++ b/lib/trends_FIR/hend/Hend.Tests.cs
@@ -0,0 +1,461 @@
+using Xunit;
+
+namespace QuanTAlib.Tests;
+
+public class HendTests
+{
+ private const int DefaultPeriod = 7;
+ private const double Epsilon = 1e-10;
+
+ // ── A) Constructor validation ──────────────────────────────────────
+
+ [Fact]
+ public void Constructor_PeriodTooSmall_Throws()
+ {
+ var ex = Assert.Throws(() => new Hend(period: 3));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_ValidPeriod_SetsName()
+ {
+ var hend = new Hend(period: 7);
+ Assert.Equal("Hend(7)", hend.Name);
+ }
+
+ [Fact]
+ public void Constructor_EvenPeriod_AdjustedToOdd()
+ {
+ var hend = new Hend(period: 8);
+ Assert.Equal("Hend(9)", hend.Name);
+ }
+
+ [Fact]
+ public void Constructor_MinPeriod5_Works()
+ {
+ var hend = new Hend(period: 5);
+ Assert.Equal("Hend(5)", hend.Name);
+ }
+
+ // ── B) Basic calculation ───────────────────────────────────────────
+
+ [Fact]
+ public void Update_ReturnsTValue()
+ {
+ var hend = new Hend(DefaultPeriod);
+ var result = hend.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var hend = new Hend(DefaultPeriod);
+ hend.Update(new TValue(DateTime.UtcNow, 50.0));
+ Assert.Equal(50.0, hend.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void ConstantInput_ReturnsConstant()
+ {
+ var hend = new Hend(5);
+ const double c = 42.0;
+ for (int i = 0; i < 20; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), c));
+ }
+ Assert.Equal(c, hend.Last.Value, 1e-9);
+ }
+
+ [Fact]
+ public void LinearTrend_PreservedExactly()
+ {
+ // Henderson preserves up to cubic polynomials at the CENTER of the window.
+ // For period=5, half=2, the output at bar N represents polynomial at index N-2.
+ const int period = 5;
+ int half = (period - 1) / 2;
+ var hend = new Hend(period);
+ int total = 20;
+ double lastResult = double.NaN;
+ for (int i = 0; i < total; i++)
+ {
+ double val = 10.0 + 3.0 * i;
+ var result = hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ lastResult = result.Value;
+ }
+ // Centered filter: output at bar N = polynomial value at bar N - half
+ int centerIdx = total - 1 - half;
+ double expected = 10.0 + 3.0 * centerIdx;
+ Assert.Equal(expected, lastResult, 1e-6);
+ }
+
+ [Fact]
+ public void QuadraticTrend_PreservedExactly()
+ {
+ const int period = 5;
+ int half = (period - 1) / 2;
+ var hend = new Hend(period);
+ int total = 20;
+ double lastResult = double.NaN;
+ for (int i = 0; i < total; i++)
+ {
+ double val = 5.0 + 2.0 * i + 0.5 * i * i;
+ var result = hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ lastResult = result.Value;
+ }
+ int centerIdx = total - 1 - half;
+ double expected = 5.0 + 2.0 * centerIdx + 0.5 * centerIdx * centerIdx;
+ Assert.Equal(expected, lastResult, 1e-4);
+ }
+
+ [Fact]
+ public void CubicTrend_PreservedExactly()
+ {
+ const int period = 5;
+ int half = (period - 1) / 2;
+ var hend = new Hend(period);
+ int total = 20;
+ double lastResult = double.NaN;
+ for (int i = 0; i < total; i++)
+ {
+ double val = 1.0 + 0.5 * i + 0.1 * i * i + 0.01 * i * i * i;
+ var result = hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ lastResult = result.Value;
+ }
+ int centerIdx = total - 1 - half;
+ double expected = 1.0 + 0.5 * centerIdx + 0.1 * centerIdx * centerIdx + 0.01 * centerIdx * centerIdx * centerIdx;
+ Assert.Equal(expected, lastResult, 1e-2);
+ }
+
+ // ── C) State + bar correction ──────────────────────────────────────
+
+ [Fact]
+ public void IsNew_True_AdvancesState()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 10; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i), isNew: true);
+ }
+ Assert.True(hend.IsHot);
+ }
+
+ [Fact]
+ public void IsNew_False_Rewrites()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 6; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), isNew: true);
+ }
+ var before = hend.Last.Value;
+
+ // Bar correction with different value
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(5), 200.0), isNew: false);
+ var corrected = hend.Last.Value;
+
+ // Should be different since one value changed
+ Assert.NotEqual(before, corrected);
+ }
+
+ [Fact]
+ public void IterativeCorrections_Restore()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 10; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 50.0 + i), isNew: true);
+ }
+ var snapshot = hend.Last.Value;
+
+ // Multiple corrections, then re-send same value
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 999.0), isNew: false);
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 888.0), isNew: false);
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 50.0 + 9), isNew: false);
+
+ // Last correction with original value should restore
+ Assert.Equal(snapshot, hend.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 10; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
+ }
+ Assert.True(hend.IsHot);
+
+ hend.Reset();
+ Assert.False(hend.IsHot);
+ Assert.Equal(default, hend.Last);
+ }
+
+ // ── D) Warmup / convergence ────────────────────────────────────────
+
+ [Fact]
+ public void IsHot_FlipsWhenBufferFull()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 4; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
+ Assert.False(hend.IsHot);
+ }
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(4), 100.0));
+ Assert.True(hend.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_EqualsUserPeriod()
+ {
+ var hend = new Hend(7);
+ Assert.Equal(7, hend.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──────────────────────────────────────────────────
+
+ [Fact]
+ public void NaN_SubstitutesLastValid()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 6; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
+ }
+
+ // Send NaN - should substitute last valid
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(6), double.NaN));
+
+ Assert.True(double.IsFinite(hend.Last.Value));
+ }
+
+ [Fact]
+ public void Infinity_SubstitutesLastValid()
+ {
+ var hend = new Hend(5);
+ for (int i = 0; i < 6; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
+ }
+
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(6), double.PositiveInfinity));
+ Assert.True(double.IsFinite(hend.Last.Value));
+ }
+
+ [Fact]
+ public void BatchNaN_Safe()
+ {
+ double[] src = [1, 2, double.NaN, 4, 5, 6, 7, 8, 9, 10];
+ double[] output = new double[src.Length];
+ Hend.Batch(src, output, period: 5);
+
+ for (int i = 0; i < output.Length; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"output[{i}] is not finite");
+ }
+ }
+
+ // ── F) Consistency ─────────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_MatchesStreaming()
+ {
+ const int len = 50;
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < len; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ // Streaming
+ var hend = new Hend(DefaultPeriod);
+ var streaming = new double[len];
+ for (int i = 0; i < len; i++)
+ {
+ var result = hend.Update(source[i]);
+ streaming[i] = result.Value;
+ }
+
+ // Batch TSeries
+ var batchResult = Hend.Batch(source, DefaultPeriod);
+
+ for (int i = 0; i < len; i++)
+ {
+ Assert.Equal(streaming[i], batchResult[i].Value, 1e-10);
+ }
+ }
+
+ [Fact]
+ public void Span_MatchesStreaming()
+ {
+ const int len = 50;
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < len; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ // Streaming
+ var hend = new Hend(DefaultPeriod);
+ var streaming = new double[len];
+ for (int i = 0; i < len; i++)
+ {
+ var result = hend.Update(source[i]);
+ streaming[i] = result.Value;
+ }
+
+ // Span
+ double[] spanOutput = new double[len];
+ Hend.Batch(source.Values, spanOutput, DefaultPeriod);
+
+ for (int i = 0; i < len; i++)
+ {
+ Assert.Equal(streaming[i], spanOutput[i], 1e-10);
+ }
+ }
+
+ // ── G) Span API tests ──────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_Throws()
+ {
+ double[] src = [1, 2, 3, 4, 5];
+ double[] output = new double[3];
+ var ex = Assert.Throws(() => Hend.Batch(src, output, period: 5));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_PeriodTooSmall_Throws()
+ {
+ double[] src = [1, 2, 3];
+ double[] output = new double[3];
+ var ex = Assert.Throws(() => Hend.Batch(src, output, period: 3));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_EmptyInput_NoOp()
+ {
+ Hend.Batch(ReadOnlySpan.Empty, Span.Empty, period: 5);
+ Assert.True(true); // no-throw is the assertion
+ }
+
+ // ── H) Chainability ────────────────────────────────────────────────
+
+ [Fact]
+ public void Pub_Fires()
+ {
+ var hend = new Hend(5);
+ bool fired = false;
+ hend.Pub += (object? sender, in TValueEventArgs e) => fired = true;
+ hend.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(fired);
+ }
+
+ [Fact]
+ public void EventBased_Chaining()
+ {
+ var source = new TSeries();
+ var hend = new Hend(source, period: 5);
+
+ for (int i = 0; i < 10; i++)
+ {
+ source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
+ }
+ Assert.True(hend.IsHot);
+ Assert.True(double.IsFinite(hend.Last.Value));
+ }
+
+ // ── I) Dispose ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void Dispose_Idempotent()
+ {
+ var hend = new Hend(5);
+ hend.Dispose();
+ hend.Dispose(); // Should not throw
+ Assert.True(true); // no-throw is the assertion
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var hend = new Hend(source, period: 5);
+ hend.Dispose();
+
+ // Adding to source after dispose should not affect hend
+ source.Add(new TValue(DateTime.UtcNow, 999.0));
+ Assert.False(hend.IsHot);
+ }
+
+ // ── J) Henderson-specific: Wolfram-verified H5 weights ─────────────
+
+ [Fact]
+ public void H5_ConstInput_ReturnsConstant()
+ {
+ // Wolfram-verified: H5 weights = {-21/286, 42/143, 80/143, 42/143, -21/286}
+ // For constant input, sum of weights * constant = constant (weights sum to 1)
+ var hend = new Hend(5);
+ const double c = 100.0;
+
+ for (int i = 0; i < 5; i++)
+ {
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), c));
+ }
+ Assert.Equal(c, hend.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void H5_NegativeEdgeWeights_BandpassProperty()
+ {
+ // Henderson has negative weights at edges — verify filter can output
+ // values outside the min-max range of inputs (bandpass property)
+ var hend = new Hend(5);
+ // Step function: 0,0,100,0,0 — negative edge weights will push result outside [0,100]
+ double[] vals = [0, 0, 100, 0, 0];
+ TValue result = default;
+ for (int i = 0; i < 5; i++)
+ {
+ result = hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), vals[i]));
+ }
+ // Henderson H5 center weight = 80/143 ≈ 0.5594
+ // Expected: 0*w0 + 0*w1 + 100*w2 + 0*w3 + 0*w4 = 100 * 80/143 ≈ 55.944
+ double expected = 100.0 * 80.0 / 143.0;
+ Assert.Equal(expected, result.Value, 1e-6);
+ }
+
+ [Fact]
+ public void H5_Symmetric_Weights()
+ {
+ // Henderson weights are symmetric: w(k) = w(-k)
+ // Reversing the input order of a symmetric window should give same center value
+ var hend1 = new Hend(5);
+ var hend2 = new Hend(5);
+
+ double[] forward = [10, 20, 30, 40, 50];
+ double[] reverse = [50, 40, 30, 20, 10];
+
+ TValue r1 = default, r2 = default;
+ for (int i = 0; i < 5; i++)
+ {
+ r1 = hend1.Update(new TValue(DateTime.UtcNow.AddSeconds(i), forward[i]));
+ r2 = hend2.Update(new TValue(DateTime.UtcNow.AddSeconds(i), reverse[i]));
+ }
+
+ // For linear input, Henderson preserves the polynomial, so both
+ // should give 30 (the center value of the linear trend)
+ // forward: 10+20+30+40+50, reverse: 50+40+30+20+10
+ // With symmetric weights applied, sum(w*forward) + sum(w*reverse) = 2*30*sum(w) = 60
+ Assert.Equal(60.0, r1.Value + r2.Value, 1e-6);
+ }
+}
diff --git a/lib/trends_FIR/hend/Hend.Validation.Tests.cs b/lib/trends_FIR/hend/Hend.Validation.Tests.cs
new file mode 100644
index 00000000..a1a2f1d5
--- /dev/null
+++ b/lib/trends_FIR/hend/Hend.Validation.Tests.cs
@@ -0,0 +1,160 @@
+using Xunit;
+using Xunit.Abstractions;
+
+namespace QuanTAlib.Tests;
+
+public class HendValidationTests(ITestOutputHelper output)
+{
+ private readonly ValidationTestData _testData = new();
+ private readonly ITestOutputHelper _output = output;
+ private const int DefaultPeriod = 7;
+
+ // ── Batch vs Streaming consistency ──────────────────────────────────
+
+ [Fact]
+ public void BatchVsStreaming_Match()
+ {
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+ const int count = 100;
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ // Streaming
+ var hend = new Hend(DefaultPeriod);
+ var streaming = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ streaming[i] = hend.Update(source[i]).Value;
+ }
+
+ // Batch
+ var batchResult = Hend.Batch(source, DefaultPeriod);
+
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(streaming[i], batchResult[i].Value, 1e-10);
+ }
+ }
+
+ // ── Span vs Streaming consistency ──────────────────────────────────
+
+ [Fact]
+ public void SpanVsStreaming_Match()
+ {
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+ const int count = 100;
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ // Streaming
+ var hend = new Hend(DefaultPeriod);
+ var streaming = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ streaming[i] = hend.Update(source[i]).Value;
+ }
+
+ // Span
+ double[] spanOutput = new double[count];
+ Hend.Batch(source.Values, spanOutput, DefaultPeriod);
+
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(streaming[i], spanOutput[i], 1e-10);
+ }
+ }
+
+ // ── Polynomial exact-fit validation ────────────────────────────────
+
+ [Fact]
+ public void LinearPolynomial_ExactFit()
+ {
+ // Henderson preserves linear trends at the CENTER of the window.
+ // For period=7, half=3, output at bar N = polynomial at bar N-3.
+ int half = (DefaultPeriod - 1) / 2;
+ var hend = new Hend(DefaultPeriod);
+ const int total = 50;
+ const double a = 5.0, b = 3.0;
+
+ for (int i = 0; i < total; i++)
+ {
+ double val = a + b * i;
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ }
+
+ int centerIdx = total - 1 - half;
+ double expected = a + b * centerIdx;
+ _output.WriteLine($"Linear: expected={expected}, actual={hend.Last.Value}");
+ Assert.Equal(expected, hend.Last.Value, 1e-6);
+ }
+
+ [Fact]
+ public void QuadraticPolynomial_ExactFit()
+ {
+ int half = (DefaultPeriod - 1) / 2;
+ var hend = new Hend(DefaultPeriod);
+ const int total = 50;
+ const double a = 2.0, b = 1.5, c = 0.3;
+
+ for (int i = 0; i < total; i++)
+ {
+ double val = a + b * i + c * i * i;
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ }
+
+ int centerIdx = total - 1 - half;
+ double expected = a + b * centerIdx + c * centerIdx * centerIdx;
+ _output.WriteLine($"Quadratic: expected={expected}, actual={hend.Last.Value}");
+ Assert.Equal(expected, hend.Last.Value, 0.1);
+ }
+
+ [Fact]
+ public void CubicPolynomial_ExactFit()
+ {
+ int half = (DefaultPeriod - 1) / 2;
+ var hend = new Hend(DefaultPeriod);
+ const int total = 50;
+ const double a = 1.0, b = 0.5, c = 0.1, d = 0.005;
+
+ for (int i = 0; i < total; i++)
+ {
+ double val = a + b * i + c * i * i + d * i * i * i;
+ hend.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
+ }
+
+ int centerIdx = total - 1 - half;
+ double expected = a + b * centerIdx + c * centerIdx * centerIdx + d * centerIdx * centerIdx * centerIdx;
+ _output.WriteLine($"Cubic: expected={expected}, actual={hend.Last.Value}");
+ Assert.Equal(expected, hend.Last.Value, 1.0);
+ }
+
+ // ── Calculate returns hot indicator ─────────────────────────────────
+
+ [Fact]
+ public void Calculate_ReturnsHotIndicator()
+ {
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < 50; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ var (results, indicator) = Hend.Calculate(source, DefaultPeriod);
+
+ Assert.True(indicator.IsHot);
+ Assert.Equal(50, results.Count);
+ }
+}
diff --git a/lib/trends_FIR/hend/Hend.cs b/lib/trends_FIR/hend/Hend.cs
new file mode 100644
index 00000000..074c6cfd
--- /dev/null
+++ b/lib/trends_FIR/hend/Hend.cs
@@ -0,0 +1,426 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// HEND: Henderson Moving Average
+///
+///
+/// Symmetric FIR filter from the X-11 seasonal adjustment framework that
+/// preserves cubic polynomial trends without distortion. Weights are derived
+/// from the closed-form Henderson formula and can be negative at edges.
+///
+/// Calculation: Precomputed weights via Henderson (1916) closed-form formula,
+/// applied as FIR convolution over sliding window. Period must be odd >= 5.
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Hend : AbstractBase
+{
+ private readonly int _period;
+ private readonly double[] _weights;
+ private readonly RingBuffer _buffer;
+ private readonly ITValuePublisher? _source;
+ private readonly TValuePublishedHandler? _pubHandler;
+ private bool _isNew = true;
+ private bool _disposed;
+ private double _lastValidValue = double.NaN;
+ private double _p_lastValidValue = double.NaN;
+
+ public bool IsNew => _isNew;
+ public override bool IsHot => _buffer.IsFull;
+
+ ///
+ /// Creates HEND with specified period.
+ ///
+ /// Lookback period (must be odd, >= 5)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Hend(int period = 7)
+ {
+ if (period < 5)
+ {
+ throw new ArgumentException("Period must be at least 5", nameof(period));
+ }
+
+ // Ensure period is odd
+ _period = period % 2 == 0 ? period + 1 : period;
+ Name = $"Hend({_period})";
+ WarmupPeriod = _period;
+
+ _buffer = new RingBuffer(_period);
+ _weights = new double[_period];
+
+ ComputeHendersonWeights(_weights, _period);
+ }
+
+ ///
+ /// Creates HEND connected to a data source for event-based updates.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Hend(ITValuePublisher source, int period = 7) : this(period)
+ {
+ _source = source;
+ _pubHandler = Handle;
+ _source.Pub += _pubHandler;
+ }
+
+ ///
+ /// Computes Henderson filter weights using the closed-form formula.
+ /// w(k) = 315 * [(n-1)²-k²][(n²-k²)][(n+1)²-k²][3n²-16-11k²]
+ /// / {8n(n²-1)(4n²-1)(4n²-9)(4n²-25)}
+ /// where n = (period+3)/2, k ranges from -(period-1)/2 to (period-1)/2.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void ComputeHendersonWeights(Span weights, int period)
+ {
+ int half = (period - 1) / 2;
+ double n = (period + 3) * 0.5;
+ double n2 = n * n;
+ double nm1_2 = (n - 1) * (n - 1);
+ double np1_2 = (n + 1) * (n + 1);
+ double denom = 8.0 * n * (n2 - 1) * (4 * n2 - 1) * (4 * n2 - 9) * (4 * n2 - 25);
+
+ double wsum = 0.0;
+ for (int i = 0; i < period; i++)
+ {
+ int k = i - half;
+ double k2 = (double)(k * k);
+ double w = 315.0 * (nm1_2 - k2) * (n2 - k2) * (np1_2 - k2) * (3 * n2 - 16 - 11 * k2) / denom;
+ weights[i] = w;
+ wsum += w;
+ }
+
+ // Normalize to sum=1.0 (handles floating-point drift)
+ if (Math.Abs(wsum) > double.Epsilon)
+ {
+ double inv = 1.0 / wsum;
+ for (int i = 0; i < period; i++)
+ {
+ weights[i] *= inv;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ return Update(input, isNew, publish: true);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private TValue Update(TValue input, bool isNew, bool publish)
+ {
+ if (isNew)
+ {
+ _p_lastValidValue = _lastValidValue;
+ }
+ else
+ {
+ _lastValidValue = _p_lastValidValue;
+ }
+
+ double val = GetValidValue(input.Value);
+
+ if (!double.IsFinite(val))
+ {
+ Last = new TValue(input.Time, double.NaN);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+
+ if (isNew)
+ {
+ _lastValidValue = val;
+ _buffer.Add(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ // During warmup, return raw value (matching Pine behavior)
+ result = val;
+ }
+ else
+ {
+ // Full window: apply Henderson FIR convolution via DotProduct
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ else
+ {
+ // Bar correction: snapshot, compute, restore
+ _buffer.Snapshot();
+ double prevLast = _lastValidValue;
+ double prevPLast = _p_lastValidValue;
+
+ _lastValidValue = val;
+ _buffer.UpdateNewest(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+
+ // Restore buffer and state
+ _buffer.Restore();
+ _lastValidValue = prevLast;
+ _p_lastValidValue = prevPLast;
+
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ Batch(source.Values, vSpan, _period);
+ source.Times.CopyTo(tSpan);
+
+ // Restore state by replaying last period bars
+ Reset();
+ int startIndex = Math.Max(0, len - _period);
+ for (int i = startIndex; i < len; i++)
+ {
+ Update(source[i], isNew: true, publish: false);
+ }
+
+ return new TSeries(t, v);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ return input;
+ }
+ return double.IsFinite(_lastValidValue) ? _lastValidValue : double.NaN;
+ }
+
+ ///
+ /// FIR convolution using SIMD DotProduct over circular buffer.
+ /// Weight[0] corresponds to oldest bar, Weight[period-1] to newest.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double ConvolveFull(RingBuffer buffer, double[] weights)
+ {
+ ReadOnlySpan internalBuf = buffer.InternalBuffer;
+ int head = buffer.StartIndex;
+ int period = buffer.Capacity;
+
+ int part1Len = period - head;
+ double sum1 = internalBuf.Slice(head, part1Len).DotProduct(weights.AsSpan(0, part1Len));
+ double sum2 = internalBuf[..head].DotProduct(weights.AsSpan(part1Len));
+
+ return sum1 + sum2;
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ ///
+ /// Calculates HEND from a TSeries using streaming updates.
+ ///
+ public static TSeries Batch(TSeries source, int period = 7)
+ {
+ var hend = new Hend(period);
+ return hend.Update(source);
+ }
+
+ ///
+ /// Calculates Henderson Moving Average over a span of values.
+ ///
+ /// Input values
+ /// Output buffer (must be same length as source)
+ /// Period for weight calculation (must be odd, >= 5)
+ /// Value to use for NaN substitution (default: NaN)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 7, double nanValue = double.NaN)
+ {
+ if (period < 5)
+ {
+ throw new ArgumentException("Period must be at least 5", nameof(period));
+ }
+
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+
+ if (source.Length == 0)
+ {
+ return;
+ }
+
+ int usePeriod = period % 2 == 0 ? period + 1 : period;
+ int len = source.Length;
+
+ const int StackallocThreshold = 256;
+
+ // Allocate weights
+ double[]? weightsRented = usePeriod > StackallocThreshold ? ArrayPool.Shared.Rent(usePeriod) : null;
+ Span weights = usePeriod <= StackallocThreshold
+ ? stackalloc double[usePeriod]
+ : weightsRented!.AsSpan(0, usePeriod);
+
+ // Allocate ring buffer
+ double[]? ringRented = usePeriod > StackallocThreshold ? ArrayPool.Shared.Rent(usePeriod) : null;
+ Span ring = usePeriod <= StackallocThreshold
+ ? stackalloc double[usePeriod]
+ : ringRented!.AsSpan(0, usePeriod);
+
+ // Allocate NaN-corrected values array
+ double[]? cleanRented = len > StackallocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = len <= StackallocThreshold
+ ? stackalloc double[len]
+ : cleanRented!.AsSpan(0, len);
+
+ ComputeHendersonWeights(weights, usePeriod);
+
+ try
+ {
+ // Build NaN-corrected values array
+ double lastValid = nanValue;
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else if (double.IsFinite(lastValid))
+ {
+ clean[i] = lastValid;
+ }
+ else
+ {
+ clean[i] = double.NaN;
+ }
+ }
+
+ // Apply Henderson FIR convolution
+ int ringIdx = 0;
+ int count = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = clean[i];
+
+ ring[ringIdx] = val;
+ ringIdx++;
+ if (ringIdx >= usePeriod)
+ {
+ ringIdx = 0;
+ }
+
+ if (count < usePeriod)
+ {
+ count++;
+ }
+
+ if (count < usePeriod)
+ {
+ // Warmup: return raw value
+ output[i] = val;
+ continue;
+ }
+
+ // Full window: DotProduct convolution over circular buffer
+ // ringIdx points to next-write = oldest entry
+ int part1Len = usePeriod - ringIdx;
+
+ ReadOnlySpan ringRo = ring;
+ double sum = ringRo.Slice(ringIdx, part1Len).DotProduct(weights.Slice(0, part1Len))
+ + ringRo[..ringIdx].DotProduct(weights.Slice(part1Len));
+
+ output[i] = sum;
+ }
+ }
+ finally
+ {
+ if (weightsRented != null)
+ {
+ ArrayPool.Shared.Return(weightsRented);
+ }
+ if (ringRented != null)
+ {
+ ArrayPool.Shared.Return(ringRented);
+ }
+ if (cleanRented != null)
+ {
+ ArrayPool.Shared.Return(cleanRented);
+ }
+ }
+ }
+
+ ///
+ /// Creates a HEND indicator and calculates results from source.
+ ///
+ public static (TSeries Results, Hend Indicator) Calculate(TSeries source, int period = 7)
+ {
+ var indicator = new Hend(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _lastValidValue = double.NaN;
+ _p_lastValidValue = double.NaN;
+ Last = default;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing && _source != null && _pubHandler != null)
+ {
+ _source.Pub -= _pubHandler;
+ }
+ _disposed = true;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_FIR/ilrs/Ilrs.Quantower.Tests.cs b/lib/trends_FIR/ilrs/Ilrs.Quantower.Tests.cs
new file mode 100644
index 00000000..f8f242e8
--- /dev/null
+++ b/lib/trends_FIR/ilrs/Ilrs.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class IlrsIndicatorTests
+{
+ [Fact]
+ public void IlrsIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new IlrsIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("ILRS - Integral of Linear Regression Slope", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void IlrsIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new IlrsIndicator { Period = 20 };
+
+ Assert.Equal(0, IlrsIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void IlrsIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new IlrsIndicator { Period = 15 };
+
+ Assert.Contains("ILRS", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IlrsIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new IlrsIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Ilrs.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IlrsIndicator_Initialize_CreatesInternalIlrs()
+ {
+ var indicator = new IlrsIndicator { Period = 10 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void IlrsIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new IlrsIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void IlrsIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new IlrsIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void IlrsIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new IlrsIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void IlrsIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new IlrsIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void IlrsIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new IlrsIndicator { Period = 3, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void IlrsIndicator_Period_CanBeChanged()
+ {
+ var indicator = new IlrsIndicator { Period = 5 };
+ Assert.Equal(5, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, IlrsIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/ilrs/Ilrs.Quantower.cs b/lib/trends_FIR/ilrs/Ilrs.Quantower.cs
new file mode 100644
index 00000000..9d935752
--- /dev/null
+++ b/lib/trends_FIR/ilrs/Ilrs.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class IlrsIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Ilrs _ilrs = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"ILRS {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/ilrs/Ilrs.Quantower.cs";
+
+ public IlrsIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "ILRS - Integral of Linear Regression Slope";
+ Description = "Cumulative sum of rolling linear regression slope (Ehlers)";
+ _series = new LineSeries(name: $"ILRS {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _ilrs = new Ilrs(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _ilrs.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _ilrs.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/ilrs/Ilrs.Tests.cs b/lib/trends_FIR/ilrs/Ilrs.Tests.cs
new file mode 100644
index 00000000..899c23a8
--- /dev/null
+++ b/lib/trends_FIR/ilrs/Ilrs.Tests.cs
@@ -0,0 +1,400 @@
+namespace QuanTAlib.Tests;
+
+using Xunit;
+
+public class IlrsTests
+{
+ private const double Tolerance = 1e-9;
+
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ // ── A) Constructor validation ──────────────────────────────────────
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void Constructor_InvalidPeriod_Throws(int period)
+ {
+ var ex = Assert.Throws(() => new Ilrs(period));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(14)]
+ [InlineData(100)]
+ public void Constructor_ValidPeriod_Succeeds(int period)
+ {
+ var ilrs = new Ilrs(period);
+ Assert.Equal($"Ilrs({period})", ilrs.Name);
+ Assert.Equal(period, ilrs.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_Throws()
+ {
+ Assert.Throws(() => new Ilrs(null!, 14));
+ }
+
+ // ── B) Basic calculation ───────────────────────────────────────────
+
+ [Fact]
+ public void Update_ReturnsFiniteValue()
+ {
+ var ilrs = new Ilrs(14);
+ var result = ilrs.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_FirstValue_EqualsInput()
+ {
+ var ilrs = new Ilrs(14);
+ var result = ilrs.Update(new TValue(DateTime.UtcNow, 42.0));
+ Assert.Equal(42.0, result.Value, Tolerance);
+ }
+
+ [Fact]
+ public void Update_ConstantInput_IntegralStaysConstant()
+ {
+ // Constant input → slope = 0 → integral stays at initial value
+ const int period = 5;
+ const double price = 100.0;
+ var ilrs = new Ilrs(period);
+
+ double result = 0;
+ for (int i = 0; i < 50; i++)
+ {
+ result = ilrs.Update(new TValue(DateTime.UtcNow, price)).Value;
+ }
+
+ Assert.Equal(price, result, 1e-6);
+ }
+
+ [Fact]
+ public void Update_LinearTrend_IntegralFollows()
+ {
+ // For y = x (linear trend), slope = 1, so integral grows by 1 each bar
+ const int period = 5;
+ var ilrs = new Ilrs(period);
+
+ for (int i = 0; i < 20; i++)
+ {
+ var result = ilrs.Update(new TValue(DateTime.UtcNow, (double)i));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ // After warmup, integral should be growing
+ Assert.True(ilrs.Last.Value > 10);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var ilrs = new Ilrs(5);
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+
+ [Fact]
+ public void Name_IsCorrect()
+ {
+ var ilrs = new Ilrs(7);
+ Assert.Equal("Ilrs(7)", ilrs.Name);
+ }
+
+ // ── C) State + bar correction ──────────────────────────────────────
+
+ [Fact]
+ public void IsNew_True_AdvancesState()
+ {
+ var ilrs = new Ilrs(5);
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ ilrs.Update(new TValue(DateTime.UtcNow, 101.0), isNew: true);
+
+ var v1 = ilrs.Last.Value;
+ ilrs.Update(new TValue(DateTime.UtcNow, 102.0), isNew: true);
+ Assert.NotEqual(v1, ilrs.Last.Value);
+ }
+
+ [Fact]
+ public void IsNew_False_RewritesCurrentBar()
+ {
+ var ilrs = new Ilrs(5);
+ for (int i = 0; i < 8; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
+ }
+
+ var before = ilrs.Last.Value;
+ ilrs.Update(new TValue(DateTime.UtcNow, 200.0), isNew: false);
+ Assert.NotEqual(before, ilrs.Last.Value);
+ }
+
+ [Fact]
+ public void IterativeCorrections_Restore()
+ {
+ var ilrs = new Ilrs(5);
+ for (int i = 0; i < 10; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
+ }
+
+ var baseline = ilrs.Last.Value;
+ // Apply multiple corrections then revert
+ ilrs.Update(new TValue(DateTime.UtcNow, 200.0), isNew: false);
+ ilrs.Update(new TValue(DateTime.UtcNow, 300.0), isNew: false);
+ ilrs.Update(new TValue(DateTime.UtcNow, 109.0), isNew: false); // Original value
+ Assert.Equal(baseline, ilrs.Last.Value, 1e-6);
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var ilrs = new Ilrs(5);
+ for (int i = 0; i < 10; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0 + i));
+ }
+
+ ilrs.Reset();
+ Assert.False(ilrs.IsHot);
+ Assert.Equal(0, ilrs.Last.Value);
+ }
+
+ // ── D) Warmup/convergence ──────────────────────────────────────────
+
+ [Fact]
+ public void IsHot_FlipsAtPeriod()
+ {
+ const int period = 5;
+ var ilrs = new Ilrs(period);
+
+ for (int i = 1; i <= period; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0 + i));
+ if (i < period)
+ {
+ Assert.False(ilrs.IsHot, $"Should not be hot at bar {i}");
+ }
+ else
+ {
+ Assert.True(ilrs.IsHot, $"Should be hot at bar {i}");
+ }
+ }
+ }
+
+ [Fact]
+ public void WarmupPeriod_MatchesPeriod()
+ {
+ var ilrs = new Ilrs(10);
+ Assert.Equal(10, ilrs.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──────────────────────────────────────────────────
+
+ [Fact]
+ public void NaN_UsesLastValidValue()
+ {
+ var ilrs = new Ilrs(5);
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0));
+ ilrs.Update(new TValue(DateTime.UtcNow, 101.0));
+ ilrs.Update(new TValue(DateTime.UtcNow, double.NaN));
+
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+
+ [Fact]
+ public void Infinity_UsesLastValidValue()
+ {
+ var ilrs = new Ilrs(5);
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0));
+ ilrs.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+
+ [Fact]
+ public void BatchNaN_Safe()
+ {
+ var ilrs = new Ilrs(5);
+ for (int i = 0; i < 10; i++)
+ {
+ double val = i == 5 ? double.NaN : 100.0 + i;
+ ilrs.Update(new TValue(DateTime.UtcNow, val));
+ }
+
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+
+ // ── F) Consistency (4 API modes) ───────────────────────────────────
+
+ [Fact]
+ public void AllModes_ProduceSameResults()
+ {
+ const int period = 7;
+
+ // Mode 1: Streaming
+ var ilrsStream = new Ilrs(period);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = ilrsStream.Update(_data[i]).Value;
+ }
+
+ // Mode 2: Batch (TSeries)
+ var batchSeries = Ilrs.Batch(_data, period);
+
+ // Mode 3: Span
+ var spanOutput = new double[_data.Count];
+ Ilrs.Batch(_data.Values, spanOutput, period);
+
+ // Mode 4: Event-based
+ var source = new TSeries();
+ var ilrsEvent = new Ilrs(source, period);
+ var eventResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ source.Add(_data[i]);
+ eventResults[i] = ilrsEvent.Last.Value;
+ }
+
+ // Compare all modes
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchSeries.Values[i], 1e-6);
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-6);
+ Assert.Equal(streamResults[i], eventResults[i], 1e-6);
+ }
+ }
+
+ // ── G) Span API tests ──────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_Throws()
+ {
+ double[] src = [1, 2, 3];
+ double[] output = new double[2];
+ var ex = Assert.Throws(() => Ilrs.Batch(src, output, period: 5));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_PeriodTooSmall_Throws()
+ {
+ double[] src = [1, 2, 3];
+ double[] output = new double[3];
+ var ex = Assert.Throws(() => Ilrs.Batch(src, output, period: 1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_EmptyInput_NoOp()
+ {
+ Ilrs.Batch(ReadOnlySpan.Empty, Span.Empty, period: 5);
+ Assert.True(true); // no-throw is the assertion
+ }
+
+ // ── H) Chainability ────────────────────────────────────────────────
+
+ [Fact]
+ public void Pub_Fires()
+ {
+ var ilrs = new Ilrs(5);
+ bool fired = false;
+ ilrs.Pub += (object? sender, in TValueEventArgs e) => fired = true;
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(fired);
+ }
+
+ [Fact]
+ public void EventBased_Chaining()
+ {
+ var source = new TSeries();
+ var ilrs = new Ilrs(source, period: 5);
+
+ for (int i = 0; i < 10; i++)
+ {
+ source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
+ }
+ Assert.True(ilrs.IsHot);
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+
+ // ── I) Dispose ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void Dispose_Idempotent()
+ {
+ var ilrs = new Ilrs(5);
+ ilrs.Dispose();
+ ilrs.Dispose(); // Should not throw
+ Assert.True(true); // no-throw is the assertion
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var ilrs = new Ilrs(source, period: 5);
+ ilrs.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 999.0));
+ Assert.False(ilrs.IsHot);
+ }
+
+ // ── J) ILRS-specific: Integration behavior ────────────────────────
+
+ [Fact]
+ public void PositiveSlope_IntegralIncreases()
+ {
+ var ilrs = new Ilrs(5);
+ // Feed increasing prices
+ for (int i = 0; i < 10; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 100.0 + i * 10));
+ }
+
+ // Integral should be well above starting value
+ Assert.True(ilrs.Last.Value > 100.0);
+ }
+
+ [Fact]
+ public void NegativeSlope_IntegralDecreases()
+ {
+ var ilrs = new Ilrs(5);
+ // Feed decreasing prices
+ for (int i = 0; i < 10; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, 200.0 - i * 10));
+ }
+
+ // Integral should be below starting value
+ Assert.True(ilrs.Last.Value < 200.0);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsResultsAndIndicator()
+ {
+ var (results, indicator) = Ilrs.Calculate(_data, 14);
+ Assert.Equal(_data.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Prime_SetsState()
+ {
+ var ilrs = new Ilrs(5);
+ double[] values = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109];
+ ilrs.Prime(values);
+ Assert.True(ilrs.IsHot);
+ Assert.True(double.IsFinite(ilrs.Last.Value));
+ }
+}
diff --git a/lib/trends_FIR/ilrs/Ilrs.Validation.Tests.cs b/lib/trends_FIR/ilrs/Ilrs.Validation.Tests.cs
new file mode 100644
index 00000000..8fad841b
--- /dev/null
+++ b/lib/trends_FIR/ilrs/Ilrs.Validation.Tests.cs
@@ -0,0 +1,132 @@
+namespace QuanTAlib.Tests;
+
+using Xunit;
+
+public class IlrsValidationTests
+{
+ private const int DataCount = 5000;
+ private readonly TSeries _data;
+
+ public IlrsValidationTests()
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.3, seed: 123);
+ _data = gbm.Fetch(DataCount, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ [Fact]
+ public void Batch_Matches_Streaming()
+ {
+ const int period = 14;
+ var batchResult = Ilrs.Batch(_data, period);
+
+ var ilrs = new Ilrs(period);
+ for (int i = 0; i < _data.Count; i++)
+ {
+ ilrs.Update(_data[i]);
+ Assert.Equal(batchResult.Values[i], ilrs.Last.Value, 1e-6);
+ }
+ }
+
+ [Fact]
+ public void Span_Matches_Streaming()
+ {
+ const int period = 14;
+ var spanOutput = new double[_data.Count];
+ Ilrs.Batch(_data.Values, spanOutput, period);
+
+ var ilrs = new Ilrs(period);
+ for (int i = 0; i < _data.Count; i++)
+ {
+ double expected = ilrs.Update(_data[i]).Value;
+ Assert.Equal(expected, spanOutput[i], 1e-6);
+ }
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(7)]
+ [InlineData(14)]
+ [InlineData(50)]
+ public void DifferentPeriods_ProduceValidResults(int period)
+ {
+ var ilrs = new Ilrs(period);
+ for (int i = 0; i < _data.Count; i++)
+ {
+ var result = ilrs.Update(_data[i]);
+ Assert.True(double.IsFinite(result.Value), $"Non-finite at bar {i}, period {period}");
+ }
+ Assert.True(ilrs.IsHot);
+ }
+
+ [Fact]
+ public void ConstantInput_ConvergesToConstant()
+ {
+ const int period = 14;
+ const double price = 50.0;
+ var ilrs = new Ilrs(period);
+
+ for (int i = 0; i < 200; i++)
+ {
+ ilrs.Update(new TValue(DateTime.UtcNow, price));
+ }
+
+ Assert.Equal(price, ilrs.Last.Value, 1e-6);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsHotIndicator()
+ {
+ var (results, indicator) = Ilrs.Calculate(_data, 14);
+ Assert.True(indicator.IsHot);
+ Assert.Equal(_data.Count, results.Count);
+ }
+
+ [Fact]
+ public void BarCorrection_Consistency()
+ {
+ const int period = 7;
+ var ilrs = new Ilrs(period);
+
+ for (int i = 0; i < 20; i++)
+ {
+ ilrs.Update(_data[i]);
+ }
+
+ var baseline = ilrs.Last.Value;
+
+ // Apply correction then revert
+ ilrs.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
+ Assert.NotEqual(baseline, ilrs.Last.Value);
+
+ ilrs.Update(_data[19], isNew: false);
+ Assert.Equal(baseline, ilrs.Last.Value, 1e-6);
+ }
+
+ [Fact]
+ public void SubsetStability()
+ {
+ const int period = 14;
+
+ // Run on first 100 bars
+ var ilrs1 = new Ilrs(period);
+ for (int i = 0; i < 100; i++)
+ {
+ ilrs1.Update(_data[i]);
+ }
+ double val100 = ilrs1.Last.Value;
+
+ // Run on first 200 bars, check the output at bar 99 matches
+ var ilrs2 = new Ilrs(period);
+ double val100_from200 = 0;
+ for (int i = 0; i < 200; i++)
+ {
+ ilrs2.Update(_data[i]);
+ if (i == 99)
+ {
+ val100_from200 = ilrs2.Last.Value;
+ }
+ }
+
+ Assert.Equal(val100, val100_from200, 1e-9);
+ }
+}
diff --git a/lib/trends_FIR/ilrs/Ilrs.cs b/lib/trends_FIR/ilrs/Ilrs.cs
new file mode 100644
index 00000000..bf571f6b
--- /dev/null
+++ b/lib/trends_FIR/ilrs/Ilrs.cs
@@ -0,0 +1,416 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// ILRS: Integral of Linear Regression Slope
+///
+///
+/// Computes the linear regression slope over a rolling window, then accumulates
+/// it via discrete integration (running sum) to reconstruct a smoothed price-level
+/// signal. The integration step introduces a natural momentum quality.
+///
+/// Algorithm: slope via O(1) incremental linreg, then ILRS += slope.
+/// Initialized to first price value.
+///
+/// Reference: John Ehlers, "Rocket Science for Traders" (Wiley, 2001).
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Ilrs : AbstractBase
+{
+ private readonly int _period;
+ private readonly RingBuffer _buffer;
+
+ private readonly double _sumX;
+ private readonly double _denominator;
+ private readonly TValuePublishedHandler _handler;
+ private ITValuePublisher? _source;
+ private int _disposed;
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(
+ double SumY, double SumXY,
+ double Integral, double LastVal,
+ double LastValidValue, bool Initialized);
+ private State _s;
+ private State _ps;
+
+ private int _tickCount;
+ private bool _isNew;
+
+ private const int ResyncInterval = 1000;
+
+ public override bool IsHot => _buffer.IsFull;
+ public bool IsNew => _isNew;
+
+ ///
+ /// Creates ILRS with specified period.
+ ///
+ /// Lookback window for slope calculation (must be >= 2)
+ public Ilrs(int period = 14)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+
+ _period = period;
+ _buffer = new RingBuffer(period);
+ Name = $"Ilrs({period})";
+ WarmupPeriod = period;
+ _handler = Handle;
+
+ // Precompute constants (reversed-x convention: x=0=newest, x=n-1=oldest)
+ _sumX = 0.5 * period * (period - 1);
+ double sumX2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
+ _denominator = period * sumX2 - _sumX * _sumX;
+ _s.LastValidValue = double.NaN;
+ }
+
+ public Ilrs(ITValuePublisher source, int period = 14) : this(period)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ _source.Pub += _handler;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ return Update(input, isNew, publish: true);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private TValue Update(TValue input, bool isNew, bool publish)
+ {
+ if (isNew)
+ {
+ double val = GetValidValue(input.Value);
+ UpdateState(val);
+ _s.LastVal = val;
+ _ps = _s;
+ }
+ else
+ {
+ _s.LastValidValue = _ps.LastValidValue;
+ double val = GetValidValue(input.Value);
+
+ // Bar correction: recalculate slope with updated newest value
+ _s.SumY = _ps.SumY - _ps.LastVal + val;
+ _s.SumXY = _ps.SumXY;
+
+ _buffer.UpdateNewest(val);
+ _s.LastVal = val;
+
+ // Recompute slope and re-apply to previous integral
+ _s.Integral = _ps.Integral - ComputeSlope(_ps) + ComputeSlope(_s);
+ }
+
+ double result;
+ if (!_s.Initialized || _buffer.Count < 2)
+ {
+ result = _s.Initialized ? _s.Integral : input.Value;
+ }
+ else
+ {
+ result = _s.Integral;
+ }
+
+ Last = new TValue(input.Time, result);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ Batch(source.Values, vSpan, _period);
+ source.Times.CopyTo(tSpan);
+
+ // Restore state by replaying entire series (integral is cumulative)
+ Reset();
+ for (int i = 0; i < len; i++)
+ {
+ Update(source[i], isNew: true, publish: false);
+ }
+
+ Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
+ return new TSeries(t, v);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ _s.LastValidValue = input;
+ return input;
+ }
+ return _s.LastValidValue;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void UpdateState(double val)
+ {
+ if (_buffer.IsFull)
+ {
+ double oldest = _buffer.Oldest;
+ double prevSumY = _s.SumY;
+
+ // O(1) update for SumXY (reversed-x convention)
+ _s.SumXY = Math.FusedMultiplyAdd(-_period, oldest, _s.SumXY + prevSumY);
+ _s.SumY = _s.SumY - oldest + val;
+ _buffer.Add(val);
+ }
+ else
+ {
+ if (_buffer.Count > 0)
+ {
+ _s.SumXY += _s.SumY;
+ }
+ _s.SumY += val;
+ _buffer.Add(val);
+ }
+
+ // Initialize integral on first value
+ if (!_s.Initialized)
+ {
+ _s.Integral = val;
+ _s.Initialized = true;
+ }
+ else if (_buffer.Count >= 2)
+ {
+ // Integrate: ILRS += slope
+ _s.Integral += ComputeSlope(_s);
+ }
+
+ _tickCount++;
+ if (_buffer.IsFull && _tickCount >= ResyncInterval)
+ {
+ _tickCount = 0;
+ Resync();
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double ComputeSlope(State state)
+ {
+ int n = _buffer.Count;
+ if (n < 2)
+ {
+ return 0;
+ }
+
+ double sx = _sumX;
+ double denom = _denominator;
+
+ if (!_buffer.IsFull)
+ {
+ double nd = n;
+ sx = 0.5 * nd * (nd - 1);
+ double sx2 = (nd - 1.0) * nd * (2.0 * nd - 1.0) / 6.0;
+ denom = nd * sx2 - sx * sx;
+ }
+
+ if (Math.Abs(denom) < 1e-10)
+ {
+ return 0;
+ }
+
+ // Reversed-x accumulation inverts the sign; negate to match standard orientation
+ return -Math.FusedMultiplyAdd(n, state.SumXY, -sx * state.SumY) / denom;
+ }
+
+ private void Resync()
+ {
+ _s.SumY = _buffer.Sum;
+ _s.SumXY = 0;
+ var span = _buffer.GetSpan();
+ for (int i = 0; i < span.Length; i++)
+ {
+ int x = span.Length - 1 - i;
+ _s.SumXY = Math.FusedMultiplyAdd(x, span[i], _s.SumXY);
+ }
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ ///
+ /// Calculates ILRS from a TSeries using streaming updates.
+ ///
+ public static TSeries Batch(TSeries source, int period = 14)
+ {
+ var ilrs = new Ilrs(period);
+ return ilrs.Update(source);
+ }
+
+ ///
+ /// Calculates ILRS in-place, writing results to pre-allocated output span.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 14)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ const int StackAllocThreshold = 256;
+ Span buffer = period <= StackAllocThreshold
+ ? stackalloc double[period]
+ : new double[period];
+
+ double sumY = 0;
+ double sumXY = 0;
+ double lastValid = double.NaN;
+ double integral = double.NaN;
+ int bufferIndex = 0;
+ int count = 0;
+
+ // Precalculate constants for full period
+ double fullSumX = 0.5 * period * (period - 1);
+ double fullSumX2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
+ double fullDenom = period * fullSumX2 - fullSumX * fullSumX;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ }
+ else
+ {
+ val = lastValid;
+ }
+
+ if (count < period)
+ {
+ // Warmup phase
+ buffer[count] = val;
+ count++;
+
+ if (count > 1)
+ {
+ sumXY += sumY;
+ }
+ sumY += val;
+
+ if (!double.IsFinite(integral))
+ {
+ integral = val;
+ output[i] = integral;
+ }
+ else if (count < 2)
+ {
+ output[i] = integral;
+ }
+ else
+ {
+ double n = count;
+ double sx = 0.5 * n * (n - 1);
+ double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
+ double denom = n * sx2 - sx * sx;
+
+ if (Math.Abs(denom) < 1e-10)
+ {
+ output[i] = integral;
+ }
+ else
+ {
+ double slope = -Math.FusedMultiplyAdd(n, sumXY, -sx * sumY) / denom;
+ integral += slope;
+ output[i] = integral;
+ }
+ }
+
+ if (count == period)
+ {
+ bufferIndex = 0;
+ }
+ }
+ else
+ {
+ // Full buffer phase — O(1) update
+ double oldest = buffer[bufferIndex];
+ double prevSumY = sumY;
+
+ sumXY = Math.FusedMultiplyAdd(-period, oldest, sumXY + prevSumY);
+ sumY = sumY - oldest + val;
+ buffer[bufferIndex] = val;
+
+ bufferIndex++;
+ if (bufferIndex >= period)
+ {
+ bufferIndex = 0;
+ }
+
+ double slope = -Math.FusedMultiplyAdd(period, sumXY, -fullSumX * sumY) / fullDenom;
+ integral += slope;
+ output[i] = integral;
+ }
+ }
+ }
+
+ public static (TSeries Results, Ilrs Indicator) Calculate(TSeries source, int period = 14)
+ {
+ var indicator = new Ilrs(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _s = default;
+ _s.LastValidValue = double.NaN;
+ _ps = default;
+ Last = default;
+ _tickCount = 0;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null)
+ {
+ _source.Pub -= _handler;
+ _source = null;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_FIR/kaiser/Kaiser.Quantower.Tests.cs b/lib/trends_FIR/kaiser/Kaiser.Quantower.Tests.cs
new file mode 100644
index 00000000..02b8306b
--- /dev/null
+++ b/lib/trends_FIR/kaiser/Kaiser.Quantower.Tests.cs
@@ -0,0 +1,171 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class KaiserIndicatorTests
+{
+ [Fact]
+ public void KaiserIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new KaiserIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(3.0, indicator.Beta);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("KAISER - Kaiser Window Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void KaiserIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new KaiserIndicator { Period = 14 };
+
+ Assert.Equal(0, KaiserIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void KaiserIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new KaiserIndicator { Period = 10, Beta = 5.0 };
+
+ Assert.Contains("KAISER", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("5.0", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void KaiserIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new KaiserIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Kaiser.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void KaiserIndicator_Initialize_CreatesInternalKaiser()
+ {
+ var indicator = new KaiserIndicator { Period = 14 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void KaiserIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new KaiserIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void KaiserIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new KaiserIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void KaiserIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new KaiserIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void KaiserIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new KaiserIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void KaiserIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new KaiserIndicator { Period = 5, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void KaiserIndicator_Period_CanBeChanged()
+ {
+ var indicator = new KaiserIndicator { Period = 14 };
+ Assert.Equal(14, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, KaiserIndicator.MinHistoryDepths);
+ }
+
+ [Fact]
+ public void KaiserIndicator_Beta_CanBeChanged()
+ {
+ var indicator = new KaiserIndicator { Beta = 3.0 };
+ Assert.Equal(3.0, indicator.Beta);
+
+ indicator.Beta = 8.6;
+ Assert.Equal(8.6, indicator.Beta);
+ }
+}
diff --git a/lib/trends_FIR/kaiser/Kaiser.Quantower.cs b/lib/trends_FIR/kaiser/Kaiser.Quantower.cs
new file mode 100644
index 00000000..715651b9
--- /dev/null
+++ b/lib/trends_FIR/kaiser/Kaiser.Quantower.cs
@@ -0,0 +1,59 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class KaiserIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [InputParameter("Beta", sortIndex: 2, minimum: 0.0, maximum: 20.0, increment: 0.1, decimalPlaces: 1)]
+ public double Beta { get; set; } = 3.0;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Kaiser _kaiser = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"KAISER {Period},{Beta:F1}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/kaiser/Kaiser.Quantower.cs";
+
+ public KaiserIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "KAISER - Kaiser Window Moving Average";
+ Description = "Kaiser Window Moving Average";
+ _series = new LineSeries(name: $"KAISER {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _kaiser = new Kaiser(Period, Beta);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _kaiser.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _kaiser.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/kaiser/Kaiser.Tests.cs b/lib/trends_FIR/kaiser/Kaiser.Tests.cs
new file mode 100644
index 00000000..f5f6e522
--- /dev/null
+++ b/lib/trends_FIR/kaiser/Kaiser.Tests.cs
@@ -0,0 +1,410 @@
+namespace QuanTAlib.Tests;
+
+public class KaiserTests
+{
+ private const int DefaultPeriod = 14;
+ private const double DefaultBeta = 3.0;
+ private const double Epsilon = 1e-10;
+
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ // ── A) Constructor validation ──────────────────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(-5)]
+ public void Constructor_InvalidPeriod_Throws(int period)
+ {
+ var ex = Assert.Throws(() => new Kaiser(period));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_NegativeBeta_Throws()
+ {
+ var ex = Assert.Throws(() => new Kaiser(14, beta: -1.0));
+ Assert.Equal("beta", ex.ParamName);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(14)]
+ [InlineData(100)]
+ public void Constructor_ValidPeriod_Succeeds(int period)
+ {
+ var kaiser = new Kaiser(period);
+ Assert.Contains(period.ToString(System.Globalization.CultureInfo.InvariantCulture), kaiser.Name, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Constructor_DefaultBeta_InName()
+ {
+ var kaiser = new Kaiser(14, 3.0);
+ Assert.Equal("Kaiser(14,3.0)", kaiser.Name);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_Throws()
+ {
+ Assert.Throws(() => new Kaiser(null!, DefaultPeriod));
+ }
+
+ // ── B) Basic calculation ───────────────────────────────────────────
+
+ [Fact]
+ public void Update_ReturnsTValue()
+ {
+ var kaiser = new Kaiser(DefaultPeriod);
+ var result = kaiser.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var kaiser = new Kaiser(DefaultPeriod);
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(kaiser.Last.Value));
+ }
+
+ [Fact]
+ public void Name_IsCorrect()
+ {
+ var kaiser = new Kaiser(14, 5.0);
+ Assert.Equal("Kaiser(14,5.0)", kaiser.Name);
+ }
+
+ [Fact]
+ public void Update_ReturnsFiniteValue()
+ {
+ var kaiser = new Kaiser(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ var result = kaiser.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ }
+
+ // ── C) State + bar correction ──────────────────────────────────────
+
+ [Fact]
+ public void IsNew_True_AdvancesState()
+ {
+ var kaiser = new Kaiser(5);
+ var now = DateTime.UtcNow;
+ kaiser.Update(new TValue(now, 10.0), isNew: true);
+ kaiser.Update(new TValue(now.AddMinutes(1), 20.0), isNew: true);
+ Assert.True(double.IsFinite(kaiser.Last.Value));
+ }
+
+ [Fact]
+ public void IsNew_False_RewritesCurrentBar()
+ {
+ var kaiser = new Kaiser(5, 3.0);
+ var now = DateTime.UtcNow;
+
+ for (int i = 0; i < 10; i++)
+ {
+ kaiser.Update(new TValue(now.AddMinutes(i), 100.0 + i), isNew: true);
+ }
+
+ double beforeCorrection = kaiser.Last.Value;
+ kaiser.Update(new TValue(now.AddMinutes(9), 999.0), isNew: false);
+ double afterCorrection = kaiser.Last.Value;
+
+ Assert.NotEqual(beforeCorrection, afterCorrection, Epsilon);
+ }
+
+ [Fact]
+ public void IterativeCorrections_Restore()
+ {
+ var kaiser = new Kaiser(5, 3.0);
+ var now = DateTime.UtcNow;
+
+ for (int i = 0; i < 10; i++)
+ {
+ kaiser.Update(new TValue(now.AddMinutes(i), 100.0 + i), isNew: true);
+ }
+
+ double original = kaiser.Last.Value;
+
+ for (int c = 0; c < 5; c++)
+ {
+ kaiser.Update(new TValue(now.AddMinutes(9), 200.0 + c), isNew: false);
+ }
+
+ kaiser.Update(new TValue(now.AddMinutes(9), 109.0), isNew: false);
+ Assert.Equal(original, kaiser.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var kaiser = new Kaiser(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ kaiser.Update(tv);
+ }
+
+ kaiser.Reset();
+ Assert.False(kaiser.IsHot);
+ }
+
+ // ── D) Warmup/convergence ──────────────────────────────────────────
+
+ [Fact]
+ public void IsHot_FlipsAtPeriod()
+ {
+ var kaiser = new Kaiser(5);
+ for (int i = 0; i < 4; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0 + i));
+ Assert.False(kaiser.IsHot);
+ }
+ kaiser.Update(new TValue(DateTime.UtcNow, 105.0));
+ Assert.True(kaiser.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_MatchesPeriod()
+ {
+ var kaiser = new Kaiser(10);
+ Assert.Equal(10, kaiser.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──────────────────────────────────────────────────
+
+ [Fact]
+ public void NaN_UsesLastValidValue()
+ {
+ var kaiser = new Kaiser(5);
+ for (int i = 0; i < 5; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ kaiser.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(kaiser.Last.Value));
+ }
+
+ [Fact]
+ public void Infinity_UsesLastValidValue()
+ {
+ var kaiser = new Kaiser(5);
+ for (int i = 0; i < 5; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ kaiser.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(kaiser.Last.Value));
+ }
+
+ [Fact]
+ public void BatchNaN_Safe()
+ {
+ var kaiser = new Kaiser(5);
+ var src = MakeSeries(50);
+ var result = kaiser.Update(src);
+ Assert.Equal(src.Count, result.Count);
+ for (int i = 0; i < result.Count; i++)
+ {
+ Assert.True(double.IsFinite(result[i].Value));
+ }
+ }
+
+ // ── F) Consistency (4-API match) ───────────────────────────────────
+
+ [Fact]
+ public void AllModes_ProduceSameResults()
+ {
+ int period = 10;
+ double beta = 3.0;
+ var src = MakeSeries(100);
+
+ // Streaming
+ var streaming = new Kaiser(period, beta);
+ var streamResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ streamResults[i] = streaming.Update(src[i]).Value;
+ }
+
+ // Batch (TSeries)
+ var batchResults = Kaiser.Batch(src, period, beta);
+
+ // Span
+ var spanOutput = new double[src.Count];
+ Kaiser.Batch(src.Values, spanOutput, period, beta);
+
+ // Event-based
+ var publisher = new TSeries();
+ var eventKaiser = new Kaiser(publisher, period, beta);
+ var eventResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ publisher.Add(src[i], isNew: true);
+ eventResults[i] = eventKaiser.Last.Value;
+ }
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-6);
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-6);
+ Assert.Equal(streamResults[i], eventResults[i], 1e-6);
+ }
+ }
+
+ // ── G) Span API tests ──────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_Throws()
+ {
+ var src = new double[10];
+ var output = new double[5];
+ var ex = Assert.Throws(() => Kaiser.Batch(src, output, 5));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_PeriodTooSmall_Throws()
+ {
+ var src = new double[10];
+ var output = new double[10];
+ var ex = Assert.Throws(() => Kaiser.Batch(src, output, 1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_EmptyInput_NoOp()
+ {
+ var src = ReadOnlySpan.Empty;
+ var output = Span.Empty;
+ Kaiser.Batch(src, output, 5);
+ Assert.True(true);
+ }
+
+ // ── H) Chainability ────────────────────────────────────────────────
+
+ [Fact]
+ public void Pub_Fires()
+ {
+ var kaiser = new Kaiser(5);
+ int count = 0;
+ kaiser.Pub += (object? _, in TValueEventArgs _) => count++;
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.Equal(1, count);
+ }
+
+ [Fact]
+ public void EventBased_Chaining()
+ {
+ var source = new TSeries();
+ using var kaiser = new Kaiser(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.True(double.IsFinite(kaiser.Last.Value));
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var kaiser = new Kaiser(source, 5);
+ kaiser.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.Equal(default, kaiser.Last);
+ }
+
+ [Fact]
+ public void Dispose_Idempotent()
+ {
+ var kaiser = new Kaiser(5);
+ kaiser.Dispose();
+ kaiser.Dispose();
+ Assert.True(true);
+ }
+
+ // ── I) Kaiser-specific: beta behavior ──────────────────────────────
+
+ [Fact]
+ public void BetaZero_ReducesToSma()
+ {
+ int period = 5;
+ var kaiser = new Kaiser(period, beta: 0.0);
+ var sma = new Sma(period);
+
+ var src = MakeSeries(50);
+ for (int i = 0; i < src.Count; i++)
+ {
+ kaiser.Update(src[i]);
+ sma.Update(src[i]);
+ }
+
+ Assert.Equal(sma.Last.Value, kaiser.Last.Value, 1e-8);
+ }
+
+ [Fact]
+ public void HigherBeta_SmoothsMore()
+ {
+ var src = MakeSeries(100);
+ int period = 14;
+
+ var kaiserLow = new Kaiser(period, beta: 1.0);
+ var kaiserHigh = new Kaiser(period, beta: 8.0);
+
+ double sumDiffLow = 0;
+ double sumDiffHigh = 0;
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ double raw = src[i].Value;
+ kaiserLow.Update(src[i]);
+ kaiserHigh.Update(src[i]);
+
+ if (kaiserLow.IsHot && kaiserHigh.IsHot)
+ {
+ sumDiffLow += Math.Abs(raw - kaiserLow.Last.Value);
+ sumDiffHigh += Math.Abs(raw - kaiserHigh.Last.Value);
+ }
+ }
+
+ Assert.True(sumDiffHigh >= sumDiffLow * 0.8);
+ }
+
+ [Fact]
+ public void ConstantInput_ReturnsConstant()
+ {
+ var kaiser = new Kaiser(7, 3.0);
+ for (int i = 0; i < 20; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, kaiser.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsResultsAndIndicator()
+ {
+ var (results, indicator) = Kaiser.Calculate(_data, 14, 3.0);
+ Assert.Equal(_data.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Prime_SetsState()
+ {
+ var kaiser = new Kaiser(5, 3.0);
+ var src = MakeSeries(20);
+ kaiser.Prime(src.Values);
+ Assert.True(kaiser.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/kaiser/Kaiser.Validation.Tests.cs b/lib/trends_FIR/kaiser/Kaiser.Validation.Tests.cs
new file mode 100644
index 00000000..0da8ced2
--- /dev/null
+++ b/lib/trends_FIR/kaiser/Kaiser.Validation.Tests.cs
@@ -0,0 +1,149 @@
+namespace QuanTAlib.Tests;
+
+using Xunit;
+
+public class KaiserValidationTests
+{
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ [Fact]
+ public void Batch_Matches_Streaming()
+ {
+ int period = 14;
+ double beta = 3.0;
+
+ var streaming = new Kaiser(period, beta);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var batchResults = Kaiser.Batch(_data, period, beta);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Span_Matches_Streaming()
+ {
+ int period = 14;
+ double beta = 3.0;
+
+ var streaming = new Kaiser(period, beta);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var spanOutput = new double[_data.Count];
+ Kaiser.Batch(_data.Values, spanOutput, period, beta);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-9);
+ }
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(7)]
+ [InlineData(14)]
+ [InlineData(50)]
+ public void DifferentPeriods_ProduceValidResults(int period)
+ {
+ var kaiser = new Kaiser(period, 3.0);
+ foreach (var tv in _data)
+ {
+ var result = kaiser.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ Assert.True(kaiser.IsHot);
+ }
+
+ [Fact]
+ public void ConstantInput_ConvergesToConstant()
+ {
+ var kaiser = new Kaiser(10, 3.0);
+ for (int i = 0; i < 50; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, kaiser.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsHotIndicator()
+ {
+ var (results, indicator) = Kaiser.Calculate(_data, 14, 3.0);
+ Assert.True(indicator.IsHot);
+ Assert.Equal(_data.Count, results.Count);
+ }
+
+ [Fact]
+ public void BarCorrection_Consistency()
+ {
+ int period = 7;
+ var kaiser = new Kaiser(period, 3.0);
+
+ for (int i = 0; i < 20; i++)
+ {
+ kaiser.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
+ }
+
+ double original = kaiser.Last.Value;
+
+ kaiser.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
+ kaiser.Update(new TValue(DateTime.UtcNow, 119.0), isNew: false);
+
+ Assert.Equal(original, kaiser.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void SubsetStability()
+ {
+ int period = 10;
+ double beta = 3.0;
+ var src = MakeSeries(200);
+
+ var full = new Kaiser(period, beta);
+ for (int i = 0; i < src.Count; i++)
+ {
+ full.Update(src[i]);
+ }
+
+ var subset = new Kaiser(period, beta);
+ for (int i = 0; i < src.Count; i++)
+ {
+ subset.Update(src[i]);
+ }
+
+ Assert.Equal(full.Last.Value, subset.Last.Value, 1e-10);
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(3.0)]
+ [InlineData(5.65)]
+ [InlineData(8.6)]
+ public void DifferentBetas_ProduceValidResults(double beta)
+ {
+ var kaiser = new Kaiser(14, beta);
+ foreach (var tv in _data)
+ {
+ var result = kaiser.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ Assert.True(kaiser.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/kaiser/Kaiser.cs b/lib/trends_FIR/kaiser/Kaiser.cs
new file mode 100644
index 00000000..86b07486
--- /dev/null
+++ b/lib/trends_FIR/kaiser/Kaiser.cs
@@ -0,0 +1,418 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// KAISER: Kaiser Window Moving Average
+///
+///
+/// Symmetric FIR filter using the Kaiser-Bessel window function for optimal
+/// sidelobe attenuation. The beta parameter continuously controls the trade-off
+/// between main lobe width (transition band sharpness) and sidelobe attenuation.
+///
+/// Calculation: Precomputed weights via modified Bessel function I0, applied as
+/// FIR convolution over sliding window. Beta=0 gives SMA, beta≈5.65 Blackman,
+/// beta≈8.6 Hamming.
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Kaiser : AbstractBase
+{
+ private readonly int _period;
+ private readonly double _beta;
+ private readonly double[] _weights;
+ private readonly RingBuffer _buffer;
+ private readonly ITValuePublisher? _source;
+ private readonly TValuePublishedHandler? _pubHandler;
+ private bool _isNew = true;
+ private bool _disposed;
+ private double _lastValidValue = double.NaN;
+ private double _p_lastValidValue = double.NaN;
+
+ public bool IsNew => _isNew;
+ public override bool IsHot => _buffer.IsFull;
+
+ ///
+ /// Creates KAISER with specified period and beta.
+ ///
+ /// Lookback period (>= 2)
+ /// Shape parameter controlling sidelobe attenuation (0..20, default 3.0)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Kaiser(int period = 14, double beta = 3.0)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+ if (beta < 0)
+ {
+ throw new ArgumentException("Beta must be non-negative", nameof(beta));
+ }
+
+ _period = period;
+ _beta = beta;
+ Name = $"Kaiser({_period},{_beta:F1})";
+ WarmupPeriod = _period;
+
+ _buffer = new RingBuffer(_period);
+ _weights = new double[_period];
+
+ ComputeKaiserWeights(_weights, _period, _beta);
+ }
+
+ ///
+ /// Creates KAISER connected to a data source for event-based updates.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Kaiser(ITValuePublisher source, int period = 14, double beta = 3.0) : this(period, beta)
+ {
+ _source = source;
+ _pubHandler = Handle;
+ _source.Pub += _pubHandler;
+ }
+
+ ///
+ /// Modified Bessel function of the first kind, order 0.
+ /// 25-term power series: I0(x) = sum_{m=0}^{25} [(x/2)^m / m!]^2
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double BesselI0(double x)
+ {
+ double sum = 1.0;
+ double term = 1.0;
+ double halfX = x * 0.5;
+ for (int m = 1; m <= 25; m++)
+ {
+ term *= halfX / m;
+ sum += term * term;
+ }
+ return sum;
+ }
+
+ ///
+ /// Computes Kaiser window weights and normalizes to sum=1.
+ /// w(k) = I0(beta * sqrt(1 - t^2)) / I0(beta), where t = 2k/(N-1) - 1.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void ComputeKaiserWeights(Span weights, int period, double beta)
+ {
+ double i0Beta = BesselI0(beta);
+ double nm1 = period - 1;
+
+ double wsum = 0.0;
+ for (int k = 0; k < period; k++)
+ {
+ double t = nm1 > 0 ? (2.0 * k / nm1) - 1.0 : 0.0;
+ double argSq = 1.0 - t * t;
+ double arg = argSq > 0 ? Math.Sqrt(argSq) : 0.0;
+ double w = i0Beta > 0 ? BesselI0(beta * arg) / i0Beta : 1.0;
+ weights[k] = w;
+ wsum += w;
+ }
+
+ if (Math.Abs(wsum) > double.Epsilon)
+ {
+ double inv = 1.0 / wsum;
+ for (int k = 0; k < period; k++)
+ {
+ weights[k] *= inv;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ return Update(input, isNew, publish: true);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private TValue Update(TValue input, bool isNew, bool publish)
+ {
+ if (isNew)
+ {
+ _p_lastValidValue = _lastValidValue;
+ }
+ else
+ {
+ _lastValidValue = _p_lastValidValue;
+ }
+
+ double val = GetValidValue(input.Value);
+
+ if (!double.IsFinite(val))
+ {
+ Last = new TValue(input.Time, double.NaN);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+
+ if (isNew)
+ {
+ _lastValidValue = val;
+ _buffer.Add(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ else
+ {
+ _buffer.Snapshot();
+ double prevLast = _lastValidValue;
+ double prevPLast = _p_lastValidValue;
+
+ _lastValidValue = val;
+ _buffer.UpdateNewest(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+
+ _buffer.Restore();
+ _lastValidValue = prevLast;
+ _p_lastValidValue = prevPLast;
+
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ Batch(source.Values, vSpan, _period, _beta);
+ source.Times.CopyTo(tSpan);
+
+ Reset();
+ int startIndex = Math.Max(0, len - _period);
+ for (int i = startIndex; i < len; i++)
+ {
+ Update(source[i], isNew: true, publish: false);
+ }
+
+ return new TSeries(t, v);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ return input;
+ }
+ return double.IsFinite(_lastValidValue) ? _lastValidValue : double.NaN;
+ }
+
+ ///
+ /// FIR convolution using SIMD DotProduct over circular buffer.
+ /// Weight[0] corresponds to oldest bar, Weight[period-1] to newest.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double ConvolveFull(RingBuffer buffer, double[] weights)
+ {
+ ReadOnlySpan internalBuf = buffer.InternalBuffer;
+ int head = buffer.StartIndex;
+ int period = buffer.Capacity;
+
+ int part1Len = period - head;
+ double sum1 = internalBuf.Slice(head, part1Len).DotProduct(weights.AsSpan(0, part1Len));
+ double sum2 = internalBuf[..head].DotProduct(weights.AsSpan(part1Len));
+
+ return sum1 + sum2;
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ public static TSeries Batch(TSeries source, int period = 14, double beta = 3.0)
+ {
+ var kaiser = new Kaiser(period, beta);
+ return kaiser.Update(source);
+ }
+
+ ///
+ /// Calculates Kaiser Window MA over a span of values.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 14, double beta = 3.0, double nanValue = double.NaN)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+ if (source.Length == 0)
+ {
+ return;
+ }
+
+ int len = source.Length;
+ const int StackallocThreshold = 256;
+
+ double[]? weightsRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span weights = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : weightsRented!.AsSpan(0, period);
+
+ double[]? ringRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span ring = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : ringRented!.AsSpan(0, period);
+
+ double[]? cleanRented = len > StackallocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = len <= StackallocThreshold
+ ? stackalloc double[len]
+ : cleanRented!.AsSpan(0, len);
+
+ ComputeKaiserWeights(weights, period, beta);
+
+ try
+ {
+ double lastValid = nanValue;
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else if (double.IsFinite(lastValid))
+ {
+ clean[i] = lastValid;
+ }
+ else
+ {
+ clean[i] = double.NaN;
+ }
+ }
+
+ int ringIdx = 0;
+ int count = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = clean[i];
+
+ ring[ringIdx] = val;
+ ringIdx++;
+ if (ringIdx >= period)
+ {
+ ringIdx = 0;
+ }
+
+ if (count < period)
+ {
+ count++;
+ }
+
+ if (count < period)
+ {
+ output[i] = val;
+ continue;
+ }
+
+ int part1Len = period - ringIdx;
+
+ ReadOnlySpan ringRo = ring;
+ double sum = ringRo.Slice(ringIdx, part1Len).DotProduct(weights.Slice(0, part1Len))
+ + ringRo[..ringIdx].DotProduct(weights.Slice(part1Len));
+
+ output[i] = sum;
+ }
+ }
+ finally
+ {
+ if (weightsRented != null)
+ {
+ ArrayPool.Shared.Return(weightsRented);
+ }
+ if (ringRented != null)
+ {
+ ArrayPool.Shared.Return(ringRented);
+ }
+ if (cleanRented != null)
+ {
+ ArrayPool.Shared.Return(cleanRented);
+ }
+ }
+ }
+
+ public static (TSeries Results, Kaiser Indicator) Calculate(TSeries source, int period = 14, double beta = 3.0)
+ {
+ var indicator = new Kaiser(period, beta);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _lastValidValue = double.NaN;
+ _p_lastValidValue = double.NaN;
+ Last = default;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing && _source != null && _pubHandler != null)
+ {
+ _source.Pub -= _pubHandler;
+ }
+ _disposed = true;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_FIR/lanczos/Lanczos.Quantower.Tests.cs b/lib/trends_FIR/lanczos/Lanczos.Quantower.Tests.cs
new file mode 100644
index 00000000..ecd789ee
--- /dev/null
+++ b/lib/trends_FIR/lanczos/Lanczos.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class LanczosIndicatorTests
+{
+ [Fact]
+ public void LanczosIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new LanczosIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("LANCZOS - Lanczos (Sinc) Window Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void LanczosIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new LanczosIndicator { Period = 14 };
+
+ Assert.Equal(0, LanczosIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void LanczosIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new LanczosIndicator { Period = 10 };
+
+ Assert.Contains("LANCZOS", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void LanczosIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new LanczosIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Lanczos.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void LanczosIndicator_Initialize_CreatesInternalLanczos()
+ {
+ var indicator = new LanczosIndicator { Period = 14 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void LanczosIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new LanczosIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void LanczosIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new LanczosIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void LanczosIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new LanczosIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void LanczosIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new LanczosIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void LanczosIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new LanczosIndicator { Period = 5, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void LanczosIndicator_Period_CanBeChanged()
+ {
+ var indicator = new LanczosIndicator { Period = 14 };
+ Assert.Equal(14, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, LanczosIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/lanczos/Lanczos.Quantower.cs b/lib/trends_FIR/lanczos/Lanczos.Quantower.cs
new file mode 100644
index 00000000..2989ed64
--- /dev/null
+++ b/lib/trends_FIR/lanczos/Lanczos.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class LanczosIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Lanczos _lanczos = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"LANCZOS {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/lanczos/Lanczos.Quantower.cs";
+
+ public LanczosIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "LANCZOS - Lanczos (Sinc) Window Moving Average";
+ Description = "Lanczos (Sinc) Window Moving Average";
+ _series = new LineSeries(name: $"LANCZOS {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _lanczos = new Lanczos(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _lanczos.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _lanczos.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/lanczos/Lanczos.Tests.cs b/lib/trends_FIR/lanczos/Lanczos.Tests.cs
new file mode 100644
index 00000000..4bc60ce4
--- /dev/null
+++ b/lib/trends_FIR/lanczos/Lanczos.Tests.cs
@@ -0,0 +1,420 @@
+namespace QuanTAlib.Tests;
+
+public class LanczosTests
+{
+ private const int DefaultPeriod = 14;
+ private const double Epsilon = 1e-10;
+
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ // ── A) Constructor validation ──────────────────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(-5)]
+ public void Constructor_InvalidPeriod_Throws(int period)
+ {
+ var ex = Assert.Throws(() => new Lanczos(period));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(14)]
+ [InlineData(100)]
+ public void Constructor_ValidPeriod_Succeeds(int period)
+ {
+ var lanczos = new Lanczos(period);
+ Assert.Contains(period.ToString(System.Globalization.CultureInfo.InvariantCulture), lanczos.Name, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Constructor_DefaultName()
+ {
+ var lanczos = new Lanczos(14);
+ Assert.Equal("Lanczos(14)", lanczos.Name);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_Throws()
+ {
+ Assert.Throws(() => new Lanczos(null!, DefaultPeriod));
+ }
+
+ // ── B) Basic calculation ───────────────────────────────────────────
+
+ [Fact]
+ public void Update_ReturnsTValue()
+ {
+ var lanczos = new Lanczos(DefaultPeriod);
+ var result = lanczos.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var lanczos = new Lanczos(DefaultPeriod);
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(lanczos.Last.Value));
+ }
+
+ [Fact]
+ public void Name_IsCorrect()
+ {
+ var lanczos = new Lanczos(20);
+ Assert.Equal("Lanczos(20)", lanczos.Name);
+ }
+
+ [Fact]
+ public void Update_ReturnsFiniteValue()
+ {
+ var lanczos = new Lanczos(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ var result = lanczos.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ }
+
+ // ── C) State + bar correction ──────────────────────────────────────
+
+ [Fact]
+ public void IsNew_True_AdvancesState()
+ {
+ var lanczos = new Lanczos(5);
+ var now = DateTime.UtcNow;
+ lanczos.Update(new TValue(now, 10.0), isNew: true);
+ lanczos.Update(new TValue(now.AddMinutes(1), 20.0), isNew: true);
+ Assert.True(double.IsFinite(lanczos.Last.Value));
+ }
+
+ [Fact]
+ public void IsNew_False_DoesNotAdvanceBuffer()
+ {
+ // The Lanczos sinc window has zero weight at the newest bar position
+ // (since sinc(1)=0 at k=period-1), so we test that isNew=false does not
+ // advance the buffer by verifying state is preserved after correction.
+ var lanczos = new Lanczos(7);
+ var src = MakeSeries(20);
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ lanczos.Update(src[i], isNew: true);
+ }
+
+ double original = lanczos.Last.Value;
+
+ // Multiple corrections should not change the final result when
+ // we restore the original value
+ lanczos.Update(new TValue(src[src.Count - 1].Time, 500.0), isNew: false);
+ lanczos.Update(new TValue(src[src.Count - 1].Time, src[src.Count - 1].Value), isNew: false);
+
+ Assert.Equal(original, lanczos.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void IterativeCorrections_Restore()
+ {
+ var lanczos = new Lanczos(14);
+ var src = MakeSeries(30);
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ lanczos.Update(src[i], isNew: true);
+ }
+
+ double original = lanczos.Last.Value;
+
+ for (int c = 0; c < 5; c++)
+ {
+ lanczos.Update(new TValue(src[src.Count - 1].Time, 200.0 + c), isNew: false);
+ }
+
+ // Restore original value
+ lanczos.Update(new TValue(src[src.Count - 1].Time, src[src.Count - 1].Value), isNew: false);
+ Assert.Equal(original, lanczos.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var lanczos = new Lanczos(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ lanczos.Update(tv);
+ }
+
+ lanczos.Reset();
+ Assert.False(lanczos.IsHot);
+ }
+
+ // ── D) Warmup/convergence ──────────────────────────────────────────
+
+ [Fact]
+ public void IsHot_FlipsAtPeriod()
+ {
+ var lanczos = new Lanczos(5);
+ for (int i = 0; i < 4; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0 + i));
+ Assert.False(lanczos.IsHot);
+ }
+ lanczos.Update(new TValue(DateTime.UtcNow, 105.0));
+ Assert.True(lanczos.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_MatchesPeriod()
+ {
+ var lanczos = new Lanczos(10);
+ Assert.Equal(10, lanczos.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──────────────────────────────────────────────────
+
+ [Fact]
+ public void NaN_UsesLastValidValue()
+ {
+ var lanczos = new Lanczos(5);
+ for (int i = 0; i < 5; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ lanczos.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(lanczos.Last.Value));
+ }
+
+ [Fact]
+ public void Infinity_UsesLastValidValue()
+ {
+ var lanczos = new Lanczos(5);
+ for (int i = 0; i < 5; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ lanczos.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(lanczos.Last.Value));
+ }
+
+ [Fact]
+ public void BatchNaN_Safe()
+ {
+ var lanczos = new Lanczos(5);
+ var src = MakeSeries(50);
+ var result = lanczos.Update(src);
+ Assert.Equal(src.Count, result.Count);
+ for (int i = 0; i < result.Count; i++)
+ {
+ Assert.True(double.IsFinite(result[i].Value));
+ }
+ }
+
+ // ── F) Consistency (4-API match) ───────────────────────────────────
+
+ [Fact]
+ public void AllModes_ProduceSameResults()
+ {
+ int period = 10;
+ var src = MakeSeries(100);
+
+ // Streaming
+ var streaming = new Lanczos(period);
+ var streamResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ streamResults[i] = streaming.Update(src[i]).Value;
+ }
+
+ // Batch (TSeries)
+ var batchResults = Lanczos.Batch(src, period);
+
+ // Span
+ var spanOutput = new double[src.Count];
+ Lanczos.Batch(src.Values, spanOutput, period);
+
+ // Event-based
+ var publisher = new TSeries();
+ var eventLanczos = new Lanczos(publisher, period);
+ var eventResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ publisher.Add(src[i], isNew: true);
+ eventResults[i] = eventLanczos.Last.Value;
+ }
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-6);
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-6);
+ Assert.Equal(streamResults[i], eventResults[i], 1e-6);
+ }
+ }
+
+ // ── G) Span API tests ──────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_Throws()
+ {
+ var src = new double[10];
+ var output = new double[5];
+ var ex = Assert.Throws(() => Lanczos.Batch(src, output, 5));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_PeriodTooSmall_Throws()
+ {
+ var src = new double[10];
+ var output = new double[10];
+ var ex = Assert.Throws(() => Lanczos.Batch(src, output, 1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_EmptyInput_NoOp()
+ {
+ var src = ReadOnlySpan.Empty;
+ var output = Span.Empty;
+ Lanczos.Batch(src, output, 5);
+ Assert.True(true);
+ }
+
+ // ── H) Chainability ────────────────────────────────────────────────
+
+ [Fact]
+ public void Pub_Fires()
+ {
+ var lanczos = new Lanczos(5);
+ int count = 0;
+ lanczos.Pub += (object? _, in TValueEventArgs e) => count++;
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.Equal(1, count);
+ }
+
+ [Fact]
+ public void EventBased_Chaining()
+ {
+ var source = new TSeries();
+ using var lanczos = new Lanczos(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.True(double.IsFinite(lanczos.Last.Value));
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var lanczos = new Lanczos(source, 5);
+ lanczos.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.Equal(default, lanczos.Last);
+ }
+
+ [Fact]
+ public void Dispose_Idempotent()
+ {
+ var lanczos = new Lanczos(5);
+ lanczos.Dispose();
+ lanczos.Dispose();
+ Assert.True(true);
+ }
+
+ // ── I) Lanczos-specific: sinc properties ───────────────────────────
+
+ [Fact]
+ public void ConstantInput_ReturnsConstant()
+ {
+ var lanczos = new Lanczos(7);
+ for (int i = 0; i < 20; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, lanczos.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Period2_ReducesToSma()
+ {
+ // For period=2: k=0 -> x = -1, k=1 -> x = 1
+ // sinc(-1) = sinc(1) = 0, both weights zero -> normalization makes them equal -> SMA(2)
+ int period = 2;
+ var lanczos = new Lanczos(period);
+ var sma = new Sma(period);
+
+ var src = MakeSeries(50);
+ for (int i = 0; i < src.Count; i++)
+ {
+ lanczos.Update(src[i]);
+ sma.Update(src[i]);
+ }
+
+ Assert.Equal(sma.Last.Value, lanczos.Last.Value, 1e-8);
+ }
+
+ [Fact]
+ public void LargerPeriod_SmoothsMore()
+ {
+ var src = MakeSeries(200);
+
+ var smallPeriod = new Lanczos(5);
+ var largePeriod = new Lanczos(20);
+
+ double sumDiffSmall = 0;
+ double sumDiffLarge = 0;
+ int countSmall = 0;
+ int countLarge = 0;
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ double raw = src[i].Value;
+ smallPeriod.Update(src[i]);
+ largePeriod.Update(src[i]);
+
+ if (smallPeriod.IsHot)
+ {
+ sumDiffSmall += Math.Abs(raw - smallPeriod.Last.Value);
+ countSmall++;
+ }
+ if (largePeriod.IsHot)
+ {
+ sumDiffLarge += Math.Abs(raw - largePeriod.Last.Value);
+ countLarge++;
+ }
+ }
+
+ double avgDiffSmall = sumDiffSmall / countSmall;
+ double avgDiffLarge = sumDiffLarge / countLarge;
+
+ // Larger period should smooth more (larger avg deviation from raw)
+ Assert.True(avgDiffLarge > avgDiffSmall);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsResultsAndIndicator()
+ {
+ var (results, indicator) = Lanczos.Calculate(_data, 14);
+ Assert.Equal(_data.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Prime_SetsState()
+ {
+ var lanczos = new Lanczos(5);
+ var src = MakeSeries(20);
+ lanczos.Prime(src.Values);
+ Assert.True(lanczos.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/lanczos/Lanczos.Validation.Tests.cs b/lib/trends_FIR/lanczos/Lanczos.Validation.Tests.cs
new file mode 100644
index 00000000..a1bac2f1
--- /dev/null
+++ b/lib/trends_FIR/lanczos/Lanczos.Validation.Tests.cs
@@ -0,0 +1,148 @@
+namespace QuanTAlib.Tests;
+
+using Xunit;
+
+public class LanczosValidationTests
+{
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ [Fact]
+ public void Batch_Matches_Streaming()
+ {
+ int period = 14;
+
+ var streaming = new Lanczos(period);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var batchResults = Lanczos.Batch(_data, period);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Span_Matches_Streaming()
+ {
+ int period = 14;
+
+ var streaming = new Lanczos(period);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var spanOutput = new double[_data.Count];
+ Lanczos.Batch(_data.Values, spanOutput, period);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-9);
+ }
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(7)]
+ [InlineData(14)]
+ [InlineData(50)]
+ public void DifferentPeriods_ProduceValidResults(int period)
+ {
+ var lanczos = new Lanczos(period);
+ foreach (var tv in _data)
+ {
+ var result = lanczos.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ Assert.True(lanczos.IsHot);
+ }
+
+ [Fact]
+ public void ConstantInput_ConvergesToConstant()
+ {
+ var lanczos = new Lanczos(10);
+ for (int i = 0; i < 50; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, lanczos.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsHotIndicator()
+ {
+ var (results, indicator) = Lanczos.Calculate(_data, 14);
+ Assert.True(indicator.IsHot);
+ Assert.Equal(_data.Count, results.Count);
+ }
+
+ [Fact]
+ public void BarCorrection_Consistency()
+ {
+ int period = 7;
+ var lanczos = new Lanczos(period);
+
+ for (int i = 0; i < 20; i++)
+ {
+ lanczos.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
+ }
+
+ double original = lanczos.Last.Value;
+
+ lanczos.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
+ lanczos.Update(new TValue(DateTime.UtcNow, 119.0), isNew: false);
+
+ Assert.Equal(original, lanczos.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void SubsetStability()
+ {
+ int period = 10;
+ var src = MakeSeries(200);
+
+ var full = new Lanczos(period);
+ for (int i = 0; i < src.Count; i++)
+ {
+ full.Update(src[i]);
+ }
+
+ var subset = new Lanczos(period);
+ for (int i = 0; i < src.Count; i++)
+ {
+ subset.Update(src[i]);
+ }
+
+ Assert.Equal(full.Last.Value, subset.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void OddAndEvenPeriods_BothWork()
+ {
+ var oddLanczos = new Lanczos(7);
+ var evenLanczos = new Lanczos(8);
+
+ foreach (var tv in _data)
+ {
+ var oddResult = oddLanczos.Update(tv);
+ var evenResult = evenLanczos.Update(tv);
+ Assert.True(double.IsFinite(oddResult.Value));
+ Assert.True(double.IsFinite(evenResult.Value));
+ }
+
+ Assert.True(oddLanczos.IsHot);
+ Assert.True(evenLanczos.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/lanczos/Lanczos.cs b/lib/trends_FIR/lanczos/Lanczos.cs
new file mode 100644
index 00000000..dc233607
--- /dev/null
+++ b/lib/trends_FIR/lanczos/Lanczos.cs
@@ -0,0 +1,400 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// LANCZOS: Lanczos (Sinc) Window Moving Average
+///
+///
+/// Symmetric FIR filter using the normalized sinc function as the window shape.
+/// The sinc function is the impulse response of the ideal brick-wall low-pass filter;
+/// windowing it to finite length trades sharp cutoff for practical realizability.
+///
+/// Calculation: Precomputed weights via sinc(2k/(N-1) - 1), applied as FIR
+/// convolution over sliding window. Negative sidelobes are preserved for
+/// frequency-domain fidelity.
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Lanczos : AbstractBase
+{
+ private readonly int _period;
+ private readonly double[] _weights;
+ private readonly RingBuffer _buffer;
+ private readonly ITValuePublisher? _source;
+ private readonly TValuePublishedHandler? _pubHandler;
+ private bool _isNew = true;
+ private bool _disposed;
+ private double _lastValidValue = double.NaN;
+ private double _p_lastValidValue = double.NaN;
+
+ public bool IsNew => _isNew;
+ public override bool IsHot => _buffer.IsFull;
+
+ ///
+ /// Creates LANCZOS with specified period.
+ ///
+ /// Lookback period (>= 2)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Lanczos(int period = 14)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+
+ _period = period;
+ Name = $"Lanczos({_period.ToString(System.Globalization.CultureInfo.InvariantCulture)})";
+ WarmupPeriod = _period;
+
+ _buffer = new RingBuffer(_period);
+ _weights = new double[_period];
+
+ ComputeLanczosWeights(_weights, _period);
+ }
+
+ ///
+ /// Creates LANCZOS connected to a data source for event-based updates.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Lanczos(ITValuePublisher source, int period = 14) : this(period)
+ {
+ _source = source;
+ _pubHandler = Handle;
+ _source.Pub += _pubHandler;
+ }
+
+ ///
+ /// Computes Lanczos (sinc) window weights and normalizes to sum=1.
+ /// w(k) = sinc(2k/(N-1) - 1), where sinc(x) = sin(pi*x)/(pi*x), sinc(0) = 1.
+ /// Negative sidelobes are preserved for frequency-domain fidelity.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void ComputeLanczosWeights(Span weights, int period)
+ {
+ double nm1 = period - 1;
+
+ double wsum = 0.0;
+ for (int k = 0; k < period; k++)
+ {
+ double x = nm1 > 0 ? (2.0 * k / nm1) - 1.0 : 0.0;
+ double w;
+ if (Math.Abs(x) < 1e-10)
+ {
+ w = 1.0;
+ }
+ else
+ {
+ double piX = Math.PI * x;
+ w = Math.Sin(piX) / piX;
+ }
+ weights[k] = w;
+ wsum += w;
+ }
+
+ if (Math.Abs(wsum) > double.Epsilon)
+ {
+ double inv = 1.0 / wsum;
+ for (int k = 0; k < period; k++)
+ {
+ weights[k] *= inv;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ return Update(input, isNew, publish: true);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private TValue Update(TValue input, bool isNew, bool publish)
+ {
+ if (isNew)
+ {
+ _p_lastValidValue = _lastValidValue;
+ }
+ else
+ {
+ _lastValidValue = _p_lastValidValue;
+ }
+
+ double val = GetValidValue(input.Value);
+
+ if (!double.IsFinite(val))
+ {
+ Last = new TValue(input.Time, double.NaN);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+
+ if (isNew)
+ {
+ _lastValidValue = val;
+ _buffer.Add(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ else
+ {
+ _buffer.Snapshot();
+ double prevLast = _lastValidValue;
+ double prevPLast = _p_lastValidValue;
+
+ _lastValidValue = val;
+ _buffer.UpdateNewest(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+
+ _buffer.Restore();
+ _lastValidValue = prevLast;
+ _p_lastValidValue = prevPLast;
+
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ Batch(source.Values, vSpan, _period);
+ source.Times.CopyTo(tSpan);
+
+ Reset();
+ int startIndex = Math.Max(0, len - _period);
+ for (int i = startIndex; i < len; i++)
+ {
+ Update(source[i], isNew: true, publish: false);
+ }
+
+ return new TSeries(t, v);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ return input;
+ }
+ return double.IsFinite(_lastValidValue) ? _lastValidValue : double.NaN;
+ }
+
+ ///
+ /// FIR convolution using SIMD DotProduct over circular buffer.
+ /// Weight[0] corresponds to oldest bar, Weight[period-1] to newest.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double ConvolveFull(RingBuffer buffer, double[] weights)
+ {
+ ReadOnlySpan internalBuf = buffer.InternalBuffer;
+ int head = buffer.StartIndex;
+ int period = buffer.Capacity;
+
+ int part1Len = period - head;
+ double sum1 = internalBuf.Slice(head, part1Len).DotProduct(weights.AsSpan(0, part1Len));
+ double sum2 = internalBuf[..head].DotProduct(weights.AsSpan(part1Len));
+
+ return sum1 + sum2;
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ public static TSeries Batch(TSeries source, int period = 14)
+ {
+ var lanczos = new Lanczos(period);
+ return lanczos.Update(source);
+ }
+
+ ///
+ /// Calculates Lanczos Window MA over a span of values.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 14, double nanValue = double.NaN)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+ if (source.Length == 0)
+ {
+ return;
+ }
+
+ int len = source.Length;
+ const int StackallocThreshold = 256;
+
+ double[]? weightsRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span weights = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : weightsRented!.AsSpan(0, period);
+
+ double[]? ringRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span ring = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : ringRented!.AsSpan(0, period);
+
+ double[]? cleanRented = len > StackallocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = len <= StackallocThreshold
+ ? stackalloc double[len]
+ : cleanRented!.AsSpan(0, len);
+
+ ComputeLanczosWeights(weights, period);
+
+ try
+ {
+ double lastValid = nanValue;
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else if (double.IsFinite(lastValid))
+ {
+ clean[i] = lastValid;
+ }
+ else
+ {
+ clean[i] = double.NaN;
+ }
+ }
+
+ int ringIdx = 0;
+ int count = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = clean[i];
+
+ ring[ringIdx] = val;
+ ringIdx++;
+ if (ringIdx >= period)
+ {
+ ringIdx = 0;
+ }
+
+ if (count < period)
+ {
+ count++;
+ }
+
+ if (count < period)
+ {
+ output[i] = val;
+ continue;
+ }
+
+ int part1Len = period - ringIdx;
+
+ ReadOnlySpan ringRo = ring;
+ double sum = ringRo.Slice(ringIdx, part1Len).DotProduct(weights.Slice(0, part1Len))
+ + ringRo[..ringIdx].DotProduct(weights.Slice(part1Len));
+
+ output[i] = sum;
+ }
+ }
+ finally
+ {
+ if (weightsRented != null)
+ {
+ ArrayPool.Shared.Return(weightsRented);
+ }
+ if (ringRented != null)
+ {
+ ArrayPool.Shared.Return(ringRented);
+ }
+ if (cleanRented != null)
+ {
+ ArrayPool.Shared.Return(cleanRented);
+ }
+ }
+ }
+
+ public static (TSeries Results, Lanczos Indicator) Calculate(TSeries source, int period = 14)
+ {
+ var indicator = new Lanczos(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _lastValidValue = double.NaN;
+ _p_lastValidValue = double.NaN;
+ Last = default;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing && _source != null && _pubHandler != null)
+ {
+ _source.Pub -= _pubHandler;
+ }
+ _disposed = true;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_IIR/nlma/Nlma.md b/lib/trends_FIR/nlma/Nlma.md
similarity index 100%
rename from lib/trends_IIR/nlma/Nlma.md
rename to lib/trends_FIR/nlma/Nlma.md
diff --git a/lib/trends_IIR/nlma/nlma.pine b/lib/trends_FIR/nlma/nlma.pine
similarity index 100%
rename from lib/trends_IIR/nlma/nlma.pine
rename to lib/trends_FIR/nlma/nlma.pine
diff --git a/lib/trends_IIR/nyqma/Nyqma.md b/lib/trends_FIR/nyqma/Nyqma.md
similarity index 100%
rename from lib/trends_IIR/nyqma/Nyqma.md
rename to lib/trends_FIR/nyqma/Nyqma.md
diff --git a/lib/trends_IIR/nyqma/nyqma.pine b/lib/trends_FIR/nyqma/nyqma.pine
similarity index 100%
rename from lib/trends_IIR/nyqma/nyqma.pine
rename to lib/trends_FIR/nyqma/nyqma.pine
diff --git a/lib/trends_FIR/parzen/Parzen.Quantower.Tests.cs b/lib/trends_FIR/parzen/Parzen.Quantower.Tests.cs
new file mode 100644
index 00000000..418c24f4
--- /dev/null
+++ b/lib/trends_FIR/parzen/Parzen.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class ParzenIndicatorTests
+{
+ [Fact]
+ public void ParzenIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new ParzenIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("PARZEN - Parzen (de la Vallée-Poussin) Window Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void ParzenIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new ParzenIndicator { Period = 14 };
+
+ Assert.Equal(0, ParzenIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void ParzenIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new ParzenIndicator { Period = 10 };
+
+ Assert.Contains("PARZEN", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ParzenIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new ParzenIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Parzen.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ParzenIndicator_Initialize_CreatesInternalParzen()
+ {
+ var indicator = new ParzenIndicator { Period = 14 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void ParzenIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new ParzenIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void ParzenIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new ParzenIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void ParzenIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new ParzenIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void ParzenIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new ParzenIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void ParzenIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new ParzenIndicator { Period = 5, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void ParzenIndicator_Period_CanBeChanged()
+ {
+ var indicator = new ParzenIndicator { Period = 14 };
+ Assert.Equal(14, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, ParzenIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/parzen/Parzen.Quantower.cs b/lib/trends_FIR/parzen/Parzen.Quantower.cs
new file mode 100644
index 00000000..08d2d66b
--- /dev/null
+++ b/lib/trends_FIR/parzen/Parzen.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class ParzenIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Parzen _parzen = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"PARZEN {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/parzen/Parzen.Quantower.cs";
+
+ public ParzenIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "PARZEN - Parzen (de la Vallée-Poussin) Window Moving Average";
+ Description = "Parzen (de la Vallée-Poussin) Window Moving Average";
+ _series = new LineSeries(name: $"PARZEN {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _parzen = new Parzen(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _parzen.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _parzen.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/parzen/Parzen.Tests.cs b/lib/trends_FIR/parzen/Parzen.Tests.cs
new file mode 100644
index 00000000..61b9bc5f
--- /dev/null
+++ b/lib/trends_FIR/parzen/Parzen.Tests.cs
@@ -0,0 +1,446 @@
+namespace QuanTAlib.Tests;
+
+public class ParzenTests
+{
+ private const int DefaultPeriod = 14;
+ private const double Epsilon = 1e-10;
+
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ // ── A) Constructor validation ──────────────────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(-5)]
+ public void Constructor_InvalidPeriod_Throws(int period)
+ {
+ var ex = Assert.Throws(() => new Parzen(period));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(14)]
+ [InlineData(100)]
+ public void Constructor_ValidPeriod_Succeeds(int period)
+ {
+ var parzen = new Parzen(period);
+ Assert.Contains(period.ToString(System.Globalization.CultureInfo.InvariantCulture), parzen.Name, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Constructor_DefaultName()
+ {
+ var parzen = new Parzen(14);
+ Assert.Equal("Parzen(14)", parzen.Name);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_Throws()
+ {
+ Assert.Throws(() => new Parzen(null!, DefaultPeriod));
+ }
+
+ // ── B) Basic calculation ───────────────────────────────────────────
+
+ [Fact]
+ public void Update_ReturnsTValue()
+ {
+ var parzen = new Parzen(DefaultPeriod);
+ var result = parzen.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Last_IsAccessible()
+ {
+ var parzen = new Parzen(DefaultPeriod);
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(parzen.Last.Value));
+ }
+
+ [Fact]
+ public void Name_IsCorrect()
+ {
+ var parzen = new Parzen(20);
+ Assert.Equal("Parzen(20)", parzen.Name);
+ }
+
+ [Fact]
+ public void Update_ReturnsFiniteValue()
+ {
+ var parzen = new Parzen(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ var result = parzen.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ }
+
+ // ── C) State + bar correction ──────────────────────────────────────
+
+ [Fact]
+ public void IsNew_True_AdvancesState()
+ {
+ var parzen = new Parzen(5);
+ var now = DateTime.UtcNow;
+ parzen.Update(new TValue(now, 10.0), isNew: true);
+ parzen.Update(new TValue(now.AddMinutes(1), 20.0), isNew: true);
+ Assert.True(double.IsFinite(parzen.Last.Value));
+ }
+
+ [Fact]
+ public void IsNew_False_DoesNotAdvanceBuffer()
+ {
+ // The Parzen window has zero weight at the boundary (|u|=1 → 2*(1-1)³=0),
+ // so the newest bar can have zero weight. Test that isNew=false does not
+ // advance the buffer by verifying state is preserved after correction.
+ var parzen = new Parzen(7);
+ var src = MakeSeries(20);
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ parzen.Update(src[i], isNew: true);
+ }
+
+ double original = parzen.Last.Value;
+
+ // Multiple corrections should not change the final result when
+ // we restore the original value
+ parzen.Update(new TValue(src[src.Count - 1].Time, 500.0), isNew: false);
+ parzen.Update(new TValue(src[src.Count - 1].Time, src[src.Count - 1].Value), isNew: false);
+
+ Assert.Equal(original, parzen.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void IterativeCorrections_Restore()
+ {
+ var parzen = new Parzen(14);
+ var src = MakeSeries(30);
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ parzen.Update(src[i], isNew: true);
+ }
+
+ double original = parzen.Last.Value;
+
+ for (int c = 0; c < 5; c++)
+ {
+ parzen.Update(new TValue(src[src.Count - 1].Time, 200.0 + c), isNew: false);
+ }
+
+ // Restore original value
+ parzen.Update(new TValue(src[src.Count - 1].Time, src[src.Count - 1].Value), isNew: false);
+ Assert.Equal(original, parzen.Last.Value, Epsilon);
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var parzen = new Parzen(DefaultPeriod);
+ foreach (var tv in _data)
+ {
+ parzen.Update(tv);
+ }
+
+ parzen.Reset();
+ Assert.False(parzen.IsHot);
+ }
+
+ // ── D) Warmup/convergence ──────────────────────────────────────────
+
+ [Fact]
+ public void IsHot_FlipsAtPeriod()
+ {
+ var parzen = new Parzen(5);
+ for (int i = 0; i < 4; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0 + i));
+ Assert.False(parzen.IsHot);
+ }
+ parzen.Update(new TValue(DateTime.UtcNow, 105.0));
+ Assert.True(parzen.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_MatchesPeriod()
+ {
+ var parzen = new Parzen(10);
+ Assert.Equal(10, parzen.WarmupPeriod);
+ }
+
+ // ── E) Robustness ──────────────────────────────────────────────────
+
+ [Fact]
+ public void NaN_UsesLastValidValue()
+ {
+ var parzen = new Parzen(5);
+ for (int i = 0; i < 5; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ parzen.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(parzen.Last.Value));
+ }
+
+ [Fact]
+ public void Infinity_UsesLastValidValue()
+ {
+ var parzen = new Parzen(5);
+ for (int i = 0; i < 5; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0));
+ }
+
+ parzen.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(parzen.Last.Value));
+ }
+
+ [Fact]
+ public void BatchNaN_Safe()
+ {
+ var parzen = new Parzen(5);
+ var src = MakeSeries(50);
+ var result = parzen.Update(src);
+ Assert.Equal(src.Count, result.Count);
+ for (int i = 0; i < result.Count; i++)
+ {
+ Assert.True(double.IsFinite(result[i].Value));
+ }
+ }
+
+ // ── F) Consistency (4-API match) ───────────────────────────────────
+
+ [Fact]
+ public void AllModes_ProduceSameResults()
+ {
+ int period = 10;
+ var src = MakeSeries(100);
+
+ // Streaming
+ var streaming = new Parzen(period);
+ var streamResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ streamResults[i] = streaming.Update(src[i]).Value;
+ }
+
+ // Batch (TSeries)
+ var batchResults = Parzen.Batch(src, period);
+
+ // Span
+ var spanOutput = new double[src.Count];
+ Parzen.Batch(src.Values, spanOutput, period);
+
+ // Event-based
+ var publisher = new TSeries();
+ var eventParzen = new Parzen(publisher, period);
+ var eventResults = new double[src.Count];
+ for (int i = 0; i < src.Count; i++)
+ {
+ publisher.Add(src[i], isNew: true);
+ eventResults[i] = eventParzen.Last.Value;
+ }
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-6);
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-6);
+ Assert.Equal(streamResults[i], eventResults[i], 1e-6);
+ }
+ }
+
+ // ── G) Span API tests ──────────────────────────────────────────────
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_Throws()
+ {
+ var src = new double[10];
+ var output = new double[5];
+ var ex = Assert.Throws(() => Parzen.Batch(src, output, 5));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_PeriodTooSmall_Throws()
+ {
+ var src = new double[10];
+ var output = new double[10];
+ var ex = Assert.Throws(() => Parzen.Batch(src, output, 1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_EmptyInput_NoOp()
+ {
+ var src = ReadOnlySpan.Empty;
+ var output = Span.Empty;
+ Parzen.Batch(src, output, 5);
+ Assert.True(true);
+ }
+
+ // ── H) Chainability ────────────────────────────────────────────────
+
+ [Fact]
+ public void Pub_Fires()
+ {
+ var parzen = new Parzen(5);
+ int count = 0;
+ parzen.Pub += (object? _, in TValueEventArgs e) => count++;
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.Equal(1, count);
+ }
+
+ [Fact]
+ public void EventBased_Chaining()
+ {
+ var source = new TSeries();
+ using var parzen = new Parzen(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.True(double.IsFinite(parzen.Last.Value));
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var parzen = new Parzen(source, 5);
+ parzen.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 100.0), isNew: true);
+ Assert.Equal(default, parzen.Last);
+ }
+
+ [Fact]
+ public void Dispose_Idempotent()
+ {
+ var parzen = new Parzen(5);
+ parzen.Dispose();
+ parzen.Dispose();
+ Assert.True(true);
+ }
+
+ // ── I) Parzen-specific: piecewise cubic properties ─────────────────
+
+ [Fact]
+ public void ConstantInput_ReturnsConstant()
+ {
+ var parzen = new Parzen(7);
+ for (int i = 0; i < 20; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, parzen.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Weights_AreSymmetric()
+ {
+ // Parzen window is symmetric around center
+ int period = 9;
+ var parzen1 = new Parzen(period);
+ var parzen2 = new Parzen(period);
+
+ // Feed ascending then descending series — symmetric weights means
+ // feeding [1,2,3,4,5] and [5,4,3,2,1] should give same result for center-weighted
+ var ascending = new double[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+ var descending = new double[] { 9, 8, 7, 6, 5, 4, 3, 2, 1 };
+
+ double resultAsc = 0, resultDesc = 0;
+ for (int i = 0; i < period; i++)
+ {
+ resultAsc = parzen1.Update(new TValue(DateTime.UtcNow, ascending[i])).Value;
+ resultDesc = parzen2.Update(new TValue(DateTime.UtcNow, descending[i])).Value;
+ }
+
+ // Both should give 5.0 (the mean) because symmetric weights on symmetric data
+ Assert.Equal(resultAsc, resultDesc, 1e-10);
+ }
+
+ [Fact]
+ public void LargerPeriod_SmoothsMore()
+ {
+ var src = MakeSeries(200);
+
+ var smallPeriod = new Parzen(5);
+ var largePeriod = new Parzen(20);
+
+ double sumDiffSmall = 0;
+ double sumDiffLarge = 0;
+ int countSmall = 0;
+ int countLarge = 0;
+
+ for (int i = 0; i < src.Count; i++)
+ {
+ double raw = src[i].Value;
+ smallPeriod.Update(src[i]);
+ largePeriod.Update(src[i]);
+
+ if (smallPeriod.IsHot)
+ {
+ sumDiffSmall += Math.Abs(raw - smallPeriod.Last.Value);
+ countSmall++;
+ }
+ if (largePeriod.IsHot)
+ {
+ sumDiffLarge += Math.Abs(raw - largePeriod.Last.Value);
+ countLarge++;
+ }
+ }
+
+ double avgDiffSmall = sumDiffSmall / countSmall;
+ double avgDiffLarge = sumDiffLarge / countLarge;
+
+ // Larger period should smooth more (larger avg deviation from raw)
+ Assert.True(avgDiffLarge > avgDiffSmall);
+ }
+
+ [Fact]
+ public void AllWeights_NonNegative()
+ {
+ // Parzen window guarantees all non-negative weights (convex combination)
+ int period = 14;
+ var src = new double[period];
+ var output = new double[period];
+ for (int i = 0; i < period; i++)
+ {
+ src[i] = 100.0;
+ }
+ src[period - 1] = 200.0; // spike at newest
+
+ Parzen.Batch(src, output, period);
+
+ // Since all weights are non-negative, convex combination means output <= max(input)
+ // and output >= min(input)
+ Assert.True(output[period - 1] >= 100.0);
+ Assert.True(output[period - 1] <= 200.0);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsResultsAndIndicator()
+ {
+ var (results, indicator) = Parzen.Calculate(_data, 14);
+ Assert.Equal(_data.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Prime_SetsState()
+ {
+ var parzen = new Parzen(5);
+ var src = MakeSeries(20);
+ parzen.Prime(src.Values);
+ Assert.True(parzen.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/parzen/Parzen.Validation.Tests.cs b/lib/trends_FIR/parzen/Parzen.Validation.Tests.cs
new file mode 100644
index 00000000..c0402096
--- /dev/null
+++ b/lib/trends_FIR/parzen/Parzen.Validation.Tests.cs
@@ -0,0 +1,148 @@
+namespace QuanTAlib.Tests;
+
+using Xunit;
+
+public class ParzenValidationTests
+{
+ private static TSeries MakeSeries(int count = 500)
+ {
+ var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
+ }
+
+ private readonly TSeries _data = MakeSeries();
+
+ [Fact]
+ public void Batch_Matches_Streaming()
+ {
+ int period = 14;
+
+ var streaming = new Parzen(period);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var batchResults = Parzen.Batch(_data, period);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], batchResults[i].Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Span_Matches_Streaming()
+ {
+ int period = 14;
+
+ var streaming = new Parzen(period);
+ var streamResults = new double[_data.Count];
+ for (int i = 0; i < _data.Count; i++)
+ {
+ streamResults[i] = streaming.Update(_data[i]).Value;
+ }
+
+ var spanOutput = new double[_data.Count];
+ Parzen.Batch(_data.Values, spanOutput, period);
+
+ for (int i = 0; i < _data.Count; i++)
+ {
+ Assert.Equal(streamResults[i], spanOutput[i], 1e-9);
+ }
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(7)]
+ [InlineData(14)]
+ [InlineData(50)]
+ public void DifferentPeriods_ProduceValidResults(int period)
+ {
+ var parzen = new Parzen(period);
+ foreach (var tv in _data)
+ {
+ var result = parzen.Update(tv);
+ Assert.True(double.IsFinite(result.Value));
+ }
+ Assert.True(parzen.IsHot);
+ }
+
+ [Fact]
+ public void ConstantInput_ConvergesToConstant()
+ {
+ var parzen = new Parzen(10);
+ for (int i = 0; i < 50; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 42.0));
+ }
+ Assert.Equal(42.0, parzen.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void Calculate_ReturnsHotIndicator()
+ {
+ var (results, indicator) = Parzen.Calculate(_data, 14);
+ Assert.True(indicator.IsHot);
+ Assert.Equal(_data.Count, results.Count);
+ }
+
+ [Fact]
+ public void BarCorrection_Consistency()
+ {
+ int period = 7;
+ var parzen = new Parzen(period);
+
+ for (int i = 0; i < 20; i++)
+ {
+ parzen.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
+ }
+
+ double original = parzen.Last.Value;
+
+ parzen.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
+ parzen.Update(new TValue(DateTime.UtcNow, 119.0), isNew: false);
+
+ Assert.Equal(original, parzen.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void SubsetStability()
+ {
+ int period = 10;
+ var src = MakeSeries(200);
+
+ var full = new Parzen(period);
+ for (int i = 0; i < src.Count; i++)
+ {
+ full.Update(src[i]);
+ }
+
+ var subset = new Parzen(period);
+ for (int i = 0; i < src.Count; i++)
+ {
+ subset.Update(src[i]);
+ }
+
+ Assert.Equal(full.Last.Value, subset.Last.Value, 1e-10);
+ }
+
+ [Fact]
+ public void OddAndEvenPeriods_BothWork()
+ {
+ var oddParzen = new Parzen(7);
+ var evenParzen = new Parzen(8);
+
+ foreach (var tv in _data)
+ {
+ var oddResult = oddParzen.Update(tv);
+ var evenResult = evenParzen.Update(tv);
+ Assert.True(double.IsFinite(oddResult.Value));
+ Assert.True(double.IsFinite(evenResult.Value));
+ }
+
+ Assert.True(oddParzen.IsHot);
+ Assert.True(evenParzen.IsHot);
+ }
+}
diff --git a/lib/trends_FIR/parzen/Parzen.cs b/lib/trends_FIR/parzen/Parzen.cs
new file mode 100644
index 00000000..ae025601
--- /dev/null
+++ b/lib/trends_FIR/parzen/Parzen.cs
@@ -0,0 +1,408 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// PARZEN: Parzen (de la Vallée-Poussin) Window Moving Average
+///
+///
+/// Symmetric FIR filter using the Parzen piecewise cubic window function.
+/// The Parzen window is the self-convolution of two Bartlett (triangular) windows
+/// at half-length, yielding continuous first and second derivatives and -24 dB/octave
+/// sidelobe rolloff. All weights are non-negative.
+///
+/// Calculation: Precomputed piecewise cubic weights, applied as FIR convolution
+/// over sliding window. O(period) per bar.
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Parzen : AbstractBase
+{
+ private readonly int _period;
+ private readonly double[] _weights;
+ private readonly RingBuffer _buffer;
+ private readonly ITValuePublisher? _source;
+ private readonly TValuePublishedHandler? _pubHandler;
+ private bool _isNew = true;
+ private bool _disposed;
+ private double _lastValidValue = double.NaN;
+ private double _p_lastValidValue = double.NaN;
+
+ public bool IsNew => _isNew;
+ public override bool IsHot => _buffer.IsFull;
+
+ ///
+ /// Creates PARZEN with specified period.
+ ///
+ /// Lookback period (>= 2)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Parzen(int period = 14)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+
+ _period = period;
+ Name = $"Parzen({_period.ToString(System.Globalization.CultureInfo.InvariantCulture)})";
+ WarmupPeriod = _period;
+
+ _buffer = new RingBuffer(_period);
+ _weights = new double[_period];
+
+ ComputeParzenWeights(_weights, _period);
+ }
+
+ ///
+ /// Creates PARZEN connected to a data source for event-based updates.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Parzen(ITValuePublisher source, int period = 14) : this(period)
+ {
+ _source = source;
+ _pubHandler = Handle;
+ _source.Pub += _pubHandler;
+ }
+
+ ///
+ /// Computes Parzen (de la Vallée-Poussin) window weights and normalizes to sum=1.
+ /// Inner region (|u| <= 0.5): w = 1 - 6u² + 6|u|³
+ /// Outer region (0.5 < |u| <= 1.0): w = 2(1 - |u|)³
+ /// All weights are non-negative.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void ComputeParzenWeights(Span weights, int period)
+ {
+ double halfN = (period - 1) * 0.5;
+
+ double wsum = 0.0;
+ for (int k = 0; k < period; k++)
+ {
+ double u = halfN > 0 ? (k - halfN) / halfN : 0.0;
+ double absU = Math.Abs(u);
+ double w;
+ if (absU <= 0.5)
+ {
+ // Inner region: cubic spline
+ w = Math.FusedMultiplyAdd(6.0, absU * absU * absU, 1.0 - 6.0 * absU * absU);
+ }
+ else if (absU <= 1.0)
+ {
+ // Outer region: cubic taper to zero
+ double t = 1.0 - absU;
+ w = 2.0 * t * t * t;
+ }
+ else
+ {
+ w = 0.0;
+ }
+ weights[k] = w;
+ wsum += w;
+ }
+
+ if (Math.Abs(wsum) > double.Epsilon)
+ {
+ double inv = 1.0 / wsum;
+ for (int k = 0; k < period; k++)
+ {
+ weights[k] *= inv;
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ return Update(input, isNew, publish: true);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private TValue Update(TValue input, bool isNew, bool publish)
+ {
+ if (isNew)
+ {
+ _p_lastValidValue = _lastValidValue;
+ }
+ else
+ {
+ _lastValidValue = _p_lastValidValue;
+ }
+
+ double val = GetValidValue(input.Value);
+
+ if (!double.IsFinite(val))
+ {
+ Last = new TValue(input.Time, double.NaN);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+
+ if (isNew)
+ {
+ _lastValidValue = val;
+ _buffer.Add(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ else
+ {
+ _buffer.Snapshot();
+ double prevLast = _lastValidValue;
+ double prevPLast = _p_lastValidValue;
+
+ _lastValidValue = val;
+ _buffer.UpdateNewest(val);
+
+ int count = _buffer.Count;
+ double result;
+
+ if (count < _period)
+ {
+ result = val;
+ }
+ else
+ {
+ result = ConvolveFull(_buffer, _weights);
+ }
+
+ Last = new TValue(input.Time, result);
+
+ _buffer.Restore();
+ _lastValidValue = prevLast;
+ _p_lastValidValue = prevPLast;
+
+ if (publish) { PubEvent(Last, isNew); }
+ return Last;
+ }
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ Batch(source.Values, vSpan, _period);
+ source.Times.CopyTo(tSpan);
+
+ Reset();
+ int startIndex = Math.Max(0, len - _period);
+ for (int i = startIndex; i < len; i++)
+ {
+ Update(source[i], isNew: true, publish: false);
+ }
+
+ return new TSeries(t, v);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ return input;
+ }
+ return double.IsFinite(_lastValidValue) ? _lastValidValue : double.NaN;
+ }
+
+ ///
+ /// FIR convolution using SIMD DotProduct over circular buffer.
+ /// Weight[0] corresponds to oldest bar, Weight[period-1] to newest.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double ConvolveFull(RingBuffer buffer, double[] weights)
+ {
+ ReadOnlySpan internalBuf = buffer.InternalBuffer;
+ int head = buffer.StartIndex;
+ int period = buffer.Capacity;
+
+ int part1Len = period - head;
+ double sum1 = internalBuf.Slice(head, part1Len).DotProduct(weights.AsSpan(0, part1Len));
+ double sum2 = internalBuf[..head].DotProduct(weights.AsSpan(part1Len));
+
+ return sum1 + sum2;
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ public static TSeries Batch(TSeries source, int period = 14)
+ {
+ var parzen = new Parzen(period);
+ return parzen.Update(source);
+ }
+
+ ///
+ /// Calculates Parzen Window MA over a span of values.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period = 14, double nanValue = double.NaN)
+ {
+ if (period < 2)
+ {
+ throw new ArgumentException("Period must be at least 2", nameof(period));
+ }
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+ if (source.Length == 0)
+ {
+ return;
+ }
+
+ int len = source.Length;
+ const int StackallocThreshold = 256;
+
+ double[]? weightsRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span weights = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : weightsRented!.AsSpan(0, period);
+
+ double[]? ringRented = period > StackallocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span ring = period <= StackallocThreshold
+ ? stackalloc double[period]
+ : ringRented!.AsSpan(0, period);
+
+ double[]? cleanRented = len > StackallocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = len <= StackallocThreshold
+ ? stackalloc double[len]
+ : cleanRented!.AsSpan(0, len);
+
+ ComputeParzenWeights(weights, period);
+
+ try
+ {
+ double lastValid = nanValue;
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else if (double.IsFinite(lastValid))
+ {
+ clean[i] = lastValid;
+ }
+ else
+ {
+ clean[i] = double.NaN;
+ }
+ }
+
+ int ringIdx = 0;
+ int count = 0;
+
+ for (int i = 0; i < len; i++)
+ {
+ double val = clean[i];
+
+ ring[ringIdx] = val;
+ ringIdx++;
+ if (ringIdx >= period)
+ {
+ ringIdx = 0;
+ }
+
+ if (count < period)
+ {
+ count++;
+ }
+
+ if (count < period)
+ {
+ output[i] = val;
+ continue;
+ }
+
+ int part1Len = period - ringIdx;
+
+ ReadOnlySpan ringRo = ring;
+ double sum = ringRo.Slice(ringIdx, part1Len).DotProduct(weights.Slice(0, part1Len))
+ + ringRo[..ringIdx].DotProduct(weights.Slice(part1Len));
+
+ output[i] = sum;
+ }
+ }
+ finally
+ {
+ if (weightsRented != null)
+ {
+ ArrayPool.Shared.Return(weightsRented);
+ }
+ if (ringRented != null)
+ {
+ ArrayPool.Shared.Return(ringRented);
+ }
+ if (cleanRented != null)
+ {
+ ArrayPool.Shared.Return(cleanRented);
+ }
+ }
+ }
+
+ public static (TSeries Results, Parzen Indicator) Calculate(TSeries source, int period = 14)
+ {
+ var indicator = new Parzen(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _lastValidValue = double.NaN;
+ _p_lastValidValue = double.NaN;
+ Last = default;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing && _source != null && _pubHandler != null)
+ {
+ _source.Pub -= _pubHandler;
+ }
+ _disposed = true;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_IIR/pma/Pma.Quantower.Tests.cs b/lib/trends_FIR/pma/Pma.Quantower.Tests.cs
similarity index 100%
rename from lib/trends_IIR/pma/Pma.Quantower.Tests.cs
rename to lib/trends_FIR/pma/Pma.Quantower.Tests.cs
diff --git a/lib/trends_IIR/pma/Pma.Quantower.cs b/lib/trends_FIR/pma/Pma.Quantower.cs
similarity index 94%
rename from lib/trends_IIR/pma/Pma.Quantower.cs
rename to lib/trends_FIR/pma/Pma.Quantower.cs
index 7b85ccf8..d66a052b 100644
--- a/lib/trends_IIR/pma/Pma.Quantower.cs
+++ b/lib/trends_FIR/pma/Pma.Quantower.cs
@@ -26,7 +26,7 @@ public sealed class PmaIndicator : Indicator, IWatchlistIndicator
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"PMA {Period}:{_sourceName}";
- public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/pma/Pma.Quantower.cs";
+public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/pma/Pma.Quantower.cs";
public PmaIndicator()
{
diff --git a/lib/trends_IIR/pma/Pma.Tests.cs b/lib/trends_FIR/pma/Pma.Tests.cs
similarity index 100%
rename from lib/trends_IIR/pma/Pma.Tests.cs
rename to lib/trends_FIR/pma/Pma.Tests.cs
diff --git a/lib/trends_IIR/pma/Pma.Validation.Tests.cs b/lib/trends_FIR/pma/Pma.Validation.Tests.cs
similarity index 100%
rename from lib/trends_IIR/pma/Pma.Validation.Tests.cs
rename to lib/trends_FIR/pma/Pma.Validation.Tests.cs
diff --git a/lib/trends_IIR/pma/Pma.cs b/lib/trends_FIR/pma/Pma.cs
similarity index 100%
rename from lib/trends_IIR/pma/Pma.cs
rename to lib/trends_FIR/pma/Pma.cs
diff --git a/lib/trends_IIR/pma/Pma.md b/lib/trends_FIR/pma/Pma.md
similarity index 100%
rename from lib/trends_IIR/pma/Pma.md
rename to lib/trends_FIR/pma/Pma.md
diff --git a/lib/trends_IIR/pma/pma.pine b/lib/trends_FIR/pma/pma.pine
similarity index 100%
rename from lib/trends_IIR/pma/pma.pine
rename to lib/trends_FIR/pma/pma.pine
diff --git a/lib/trends_FIR/qrma/Qrma.Quantower.Tests.cs b/lib/trends_FIR/qrma/Qrma.Quantower.Tests.cs
new file mode 100644
index 00000000..c230b439
--- /dev/null
+++ b/lib/trends_FIR/qrma/Qrma.Quantower.Tests.cs
@@ -0,0 +1,159 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class QrmaIndicatorTests
+{
+ [Fact]
+ public void QrmaIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new QrmaIndicator();
+
+ Assert.Equal(14, indicator.Period);
+ Assert.Equal(SourceType.Close, indicator.Source);
+ Assert.True(indicator.ShowColdValues);
+ Assert.Equal("QRMA - Quadratic Regression Moving Average", indicator.Name);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ }
+
+ [Fact]
+ public void QrmaIndicator_MinHistoryDepths_IsZero()
+ {
+ var indicator = new QrmaIndicator { Period = 20 };
+
+ Assert.Equal(0, QrmaIndicator.MinHistoryDepths);
+ Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void QrmaIndicator_ShortName_IncludesPeriodAndSource()
+ {
+ var indicator = new QrmaIndicator { Period = 15 };
+
+ Assert.Contains("QRMA", indicator.ShortName, StringComparison.Ordinal);
+ Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void QrmaIndicator_SourceCodeLink_IsValid()
+ {
+ var indicator = new QrmaIndicator();
+
+ Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
+ Assert.Contains("Qrma.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void QrmaIndicator_Initialize_CreatesInternalQrma()
+ {
+ var indicator = new QrmaIndicator { Period = 10 };
+
+ indicator.Initialize();
+
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void QrmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new QrmaIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+
+ Assert.Equal(1, indicator.LinesSeries[0].Count);
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
+ }
+
+ [Fact]
+ public void QrmaIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new QrmaIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void QrmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
+ {
+ var indicator = new QrmaIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ double firstValue = indicator.LinesSeries[0].GetValue(0);
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
+ double secondValue = indicator.LinesSeries[0].GetValue(0);
+
+ Assert.True(double.IsFinite(firstValue));
+ Assert.True(double.IsFinite(secondValue));
+ }
+
+ [Fact]
+ public void QrmaIndicator_MultipleUpdates_ProducesCorrectSequence()
+ {
+ var indicator = new QrmaIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ double[] closes = { 100, 102, 104, 103, 105 };
+
+ foreach (var close in closes)
+ {
+ indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ now = now.AddMinutes(1);
+ }
+
+ for (int i = 0; i < closes.Length; i++)
+ {
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
+ }
+ }
+
+ [Fact]
+ public void QrmaIndicator_DifferentSourceTypes_Work()
+ {
+ var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
+
+ foreach (var source in sources)
+ {
+ var indicator = new QrmaIndicator { Period = 3, Source = source };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
+ $"Source {source} should produce finite value");
+ }
+ }
+
+ [Fact]
+ public void QrmaIndicator_Period_CanBeChanged()
+ {
+ var indicator = new QrmaIndicator { Period = 5 };
+ Assert.Equal(5, indicator.Period);
+
+ indicator.Period = 20;
+ Assert.Equal(20, indicator.Period);
+ Assert.Equal(0, QrmaIndicator.MinHistoryDepths);
+ }
+}
diff --git a/lib/trends_FIR/qrma/Qrma.Quantower.cs b/lib/trends_FIR/qrma/Qrma.Quantower.cs
new file mode 100644
index 00000000..40de9f2d
--- /dev/null
+++ b/lib/trends_FIR/qrma/Qrma.Quantower.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+[SkipLocalsInit]
+public sealed class QrmaIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 1, 3, 2000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [IndicatorExtensions.DataSourceInput]
+ public SourceType Source { get; set; } = SourceType.Close;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Qrma _qrma = null!;
+ private readonly LineSeries _series;
+ private string _sourceName = null!;
+ private Func _priceSelector = null!;
+
+ public static int MinHistoryDepths => 0;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"QRMA {Period}:{_sourceName}";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/qrma/Qrma.Quantower.cs";
+
+ public QrmaIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "QRMA - Quadratic Regression Moving Average";
+ Description = "Quadratic Regression Moving Average";
+ _series = new LineSeries(name: $"QRMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ protected override void OnInit()
+ {
+ _priceSelector = Source.GetPriceSelector();
+ _sourceName = Source.ToString();
+ _qrma = new Qrma(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ bool isNew = args.IsNewBar();
+ var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
+ double value = _qrma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
+ _series.SetValue(value, _qrma.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/qrma/Qrma.Tests.cs b/lib/trends_FIR/qrma/Qrma.Tests.cs
new file mode 100644
index 00000000..aa4c1cc4
--- /dev/null
+++ b/lib/trends_FIR/qrma/Qrma.Tests.cs
@@ -0,0 +1,459 @@
+namespace QuanTAlib.Tests;
+
+public class QrmaTests
+{
+ [Fact]
+ public void Constructor_InvalidPeriod_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new Qrma(0));
+ Assert.Throws(() => new Qrma(-1));
+ Assert.Throws(() => new Qrma(2)); // Minimum is 3
+ }
+
+ [Fact]
+ public void Constructor_ValidParameters_SetsProperties()
+ {
+ var qrma = new Qrma(14);
+ Assert.Equal("Qrma(14)", qrma.Name);
+ Assert.False(qrma.IsHot);
+ }
+
+ [Fact]
+ public void Update_SingleValue_ReturnsSameValue()
+ {
+ var qrma = new Qrma(14);
+ var result = qrma.Update(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, result.Value);
+ }
+
+ [Fact]
+ public void Update_LinearTrend_ReturnsExactValue()
+ {
+ // For a perfect linear trend y = x, quadratic regression should also return x
+ // (higher-order coefficient c becomes zero)
+ const int period = 10;
+ var qrma = new Qrma(period);
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ var result = qrma.Update(new TValue(DateTime.UtcNow, i));
+ if (i >= period) // After warmup
+ {
+ Assert.Equal(i, result.Value, 1e-6);
+ }
+ }
+ }
+
+ [Fact]
+ public void Update_QuadraticTrend_ReturnsExactValue()
+ {
+ // For y = x², quadratic regression should fit exactly
+ const int period = 10;
+ var qrma = new Qrma(period);
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ double y = (double)i * i;
+ var result = qrma.Update(new TValue(DateTime.UtcNow, y));
+ if (i >= period)
+ {
+ Assert.Equal(y, result.Value, 1e-4);
+ }
+ }
+ }
+
+ [Fact]
+ public void Update_ConstantValue_ReturnsSameValue()
+ {
+ const int period = 10;
+ var qrma = new Qrma(period);
+ const double value = 123.45;
+
+ for (int i = 0; i < period * 2; i++)
+ {
+ var result = qrma.Update(new TValue(DateTime.UtcNow, value));
+ Assert.Equal(value, result.Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Update_BarCorrection_UpdatesCorrectly()
+ {
+ var qrma = new Qrma(5);
+
+ // Fill buffer
+ for (int i = 0; i < 5; i++)
+ {
+ qrma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ // New bar
+ var result1 = qrma.Update(new TValue(DateTime.UtcNow, 10));
+
+ // Update same bar with different value
+ var result2 = qrma.Update(new TValue(DateTime.UtcNow, 20), isNew: false);
+
+ Assert.NotEqual(result1.Value, result2.Value);
+
+ // Verify internal state by adding next bar
+ var result3 = qrma.Update(new TValue(DateTime.UtcNow, 30));
+ Assert.True(double.IsFinite(result3.Value));
+ }
+
+ [Fact]
+ public void Update_IterativeCorrection_RestoresState()
+ {
+ var qrma = new Qrma(5);
+
+ // Build up state
+ for (int i = 0; i < 10; i++)
+ {
+ qrma.Update(new TValue(DateTime.UtcNow, i * 10.0));
+ }
+
+ // New bar
+ var resultNew = qrma.Update(new TValue(DateTime.UtcNow, 100));
+
+ // Multiple corrections on the same bar
+ qrma.Update(new TValue(DateTime.UtcNow, 105), isNew: false);
+ qrma.Update(new TValue(DateTime.UtcNow, 110), isNew: false);
+ var resultFinal = qrma.Update(new TValue(DateTime.UtcNow, 100), isNew: false);
+
+ // Correcting back to original value should give same result
+ Assert.Equal(resultNew.Value, resultFinal.Value, 1e-9);
+ }
+
+ [Fact]
+ public void Update_NaN_HandlesGracefully()
+ {
+ var qrma = new Qrma(5);
+
+ for (int i = 1; i <= 5; i++)
+ {
+ qrma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ // NaN should be replaced with last valid value
+ var result = qrma.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_Infinity_HandlesGracefully()
+ {
+ var qrma = new Qrma(5);
+
+ for (int i = 1; i <= 5; i++)
+ {
+ qrma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ var result = qrma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_BatchNaN_Safe()
+ {
+ var qrma = new Qrma(5);
+ qrma.Update(new TValue(DateTime.UtcNow, 10));
+
+ // Several NaN values
+ for (int i = 0; i < 5; i++)
+ {
+ var result = qrma.Update(new TValue(DateTime.UtcNow, double.NaN));
+ Assert.True(double.IsFinite(result.Value));
+ }
+ }
+
+ [Fact]
+ public void Calculate_StaticMethod_MatchesObjectInstance()
+ {
+ const int period = 10;
+ const int count = 100;
+ var source = new TSeries();
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ }
+
+ var qrma = new Qrma(period);
+ var series1 = qrma.Update(source);
+ var series2 = Qrma.Batch(source, period);
+
+ Assert.Equal(series1.Count, series2.Count);
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(series1[i].Value, series2[i].Value, 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Calculate_Span_MatchesSeries()
+ {
+ const int period = 10;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ values[i] = bar.Close;
+ }
+
+ Qrma.Batch(values, output, period);
+
+ var qrma = new Qrma(period);
+ for (int i = 0; i < count; i++)
+ {
+ var result = qrma.Update(new TValue(DateTime.UtcNow, values[i]));
+ Assert.Equal(result.Value, output[i], 1e-9);
+ }
+ }
+
+ [Fact]
+ public void Span_InvalidLength_ThrowsArgumentException()
+ {
+ var source = new double[10];
+ var output = new double[5]; // Mismatched length
+
+ var ex = Assert.Throws(() => Qrma.Batch(source, output, 3));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Span_InvalidPeriod_ThrowsArgumentException()
+ {
+ var source = new double[10];
+ var output = new double[10];
+
+ var ex = Assert.Throws(() => Qrma.Batch(source, output, 2));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Span_LargeData_DoesNotStackOverflow()
+ {
+ const int period = 20;
+ const int count = 5000;
+ var values = new double[count];
+ var output = new double[count];
+ var gbm = new GBM(startPrice: 100, seed: 42);
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = gbm.Next().Close;
+ }
+
+ // Should not throw
+ Qrma.Batch(values, output, period);
+
+ // All post-warmup values should be finite
+ for (int i = period; i < count; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
+ }
+ }
+
+ [Fact]
+ public void Span_NaN_HandledCorrectly()
+ {
+ const int period = 5;
+ var source = new double[] { 1, 2, 3, double.NaN, 5, 6, 7, 8, 9, 10 };
+ var output = new double[source.Length];
+
+ Qrma.Batch(source, output, period);
+
+ for (int i = 0; i < source.Length; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
+ }
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var qrma = new Qrma(5);
+ for (int i = 0; i < 10; i++)
+ {
+ qrma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ Assert.True(qrma.IsHot);
+
+ qrma.Reset();
+
+ Assert.False(qrma.IsHot);
+ Assert.Equal(0, qrma.Last.Value);
+
+ var result = qrma.Update(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, result.Value);
+ }
+
+ [Fact]
+ public void IsHot_BecomesTrueWhenBufferFull()
+ {
+ const int period = 5;
+ var qrma = new Qrma(period);
+
+ for (int i = 0; i < period; i++)
+ {
+ Assert.False(qrma.IsHot);
+ qrma.Update(new TValue(DateTime.UtcNow, i));
+ }
+
+ Assert.True(qrma.IsHot);
+ }
+
+ [Fact]
+ public void Chainability_Works()
+ {
+ var source = new TSeries();
+ var qrma = new Qrma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, qrma.Last.Value);
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromSource()
+ {
+ var source = new TSeries();
+ var qrma = new Qrma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+ Assert.Equal(100, qrma.Last.Value);
+
+ qrma.Dispose();
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, qrma.Last.Value); // Should remain at previous value
+ }
+
+ [Fact]
+ public void Dispose_IsIdempotent()
+ {
+ var source = new TSeries();
+ var qrma = new Qrma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+
+#pragma warning disable S3966
+ qrma.Dispose();
+ qrma.Dispose();
+#pragma warning restore S3966
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, qrma.Last.Value);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task Dispose_IsThreadSafe()
+ {
+ var source = new TSeries();
+ var qrma = new Qrma(source, 5);
+
+ source.Add(new TValue(DateTime.UtcNow, 100));
+
+ var tasks = new System.Threading.Tasks.Task[10];
+ for (int i = 0; i < tasks.Length; i++)
+ {
+ tasks[i] = System.Threading.Tasks.Task.Run(() => qrma.Dispose());
+ }
+
+ await System.Threading.Tasks.Task.WhenAll(tasks);
+
+ source.Add(new TValue(DateTime.UtcNow, 200));
+ Assert.Equal(100, qrma.Last.Value);
+ }
+
+ [Fact]
+ public void Dispose_WithoutSource_DoesNotThrow()
+ {
+ var qrma = new Qrma(5);
+
+#pragma warning disable S3966
+ qrma.Dispose();
+ qrma.Dispose();
+#pragma warning restore S3966
+
+ Assert.False(qrma.IsHot);
+ }
+
+ [Fact]
+ public void Constructor_NullSource_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new Qrma(null!, 5));
+ }
+
+ [Fact]
+ public void MinPeriod_Three_Works()
+ {
+ // QRMA minimum period is 3 (3 unknowns for quadratic)
+ var qrma = new Qrma(3);
+
+ qrma.Update(new TValue(DateTime.UtcNow, 1));
+ qrma.Update(new TValue(DateTime.UtcNow, 4));
+ var result = qrma.Update(new TValue(DateTime.UtcNow, 9));
+
+ // y = x² with x={0,1,2} → at x=2 → 4
+ // But input values are {1,4,9} which is y=(x+1)² → endpoint at x=2: a+2b+4c
+ Assert.True(double.IsFinite(result.Value));
+ Assert.True(qrma.IsHot);
+ }
+
+ [Fact]
+ public void AllModes_ProduceConsistentResults()
+ {
+ const int period = 10;
+ const int count = 50;
+ var gbm = new GBM(startPrice: 100, seed: 42);
+ var source = new TSeries();
+ var values = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ var bar = gbm.Next();
+ source.Add(bar.C);
+ values[i] = bar.Close;
+ }
+
+ // Mode 1: Streaming
+ var streaming = new Qrma(period);
+ var streamingResults = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ streamingResults[i] = streaming.Update(source[i]).Value;
+ }
+
+ // Mode 2: Batch TSeries
+ var batchResults = Qrma.Batch(source, period);
+
+ // Mode 3: Span
+ var spanOutput = new double[count];
+ Qrma.Batch(values, spanOutput, period);
+
+ // Mode 4: Event-based
+ var eventSource = new TSeries();
+ var eventQrma = new Qrma(eventSource, period);
+ var eventResults = new double[count];
+ for (int i = 0; i < count; i++)
+ {
+ eventSource.Add(source[i]);
+ eventResults[i] = eventQrma.Last.Value;
+ }
+
+ // All four modes should match
+ for (int i = 0; i < count; i++)
+ {
+ Assert.Equal(streamingResults[i], batchResults[i].Value, 1e-9);
+ Assert.Equal(streamingResults[i], spanOutput[i], 1e-9);
+ Assert.Equal(streamingResults[i], eventResults[i], 1e-9);
+ }
+ }
+}
diff --git a/lib/trends_FIR/qrma/Qrma.Validation.Tests.cs b/lib/trends_FIR/qrma/Qrma.Validation.Tests.cs
new file mode 100644
index 00000000..9c5579f3
--- /dev/null
+++ b/lib/trends_FIR/qrma/Qrma.Validation.Tests.cs
@@ -0,0 +1,140 @@
+using Xunit.Abstractions;
+
+namespace QuanTAlib.Tests;
+
+public class QrmaValidationTests
+{
+ private readonly ValidationTestData _testData;
+ private readonly ITestOutputHelper _output;
+
+ public QrmaValidationTests(ITestOutputHelper output)
+ {
+ _output = output;
+ _testData = new ValidationTestData();
+ }
+
+ [Fact]
+ public void Validate_Batch_Vs_Streaming()
+ {
+ int[] periods = { 5, 10, 14, 20, 50 };
+
+ foreach (var period in periods)
+ {
+ // Calculate QuanTAlib QRMA (batch TSeries)
+ var qrma = new global::QuanTAlib.Qrma(period);
+ var batchResult = qrma.Update(_testData.Data);
+
+ // Calculate QuanTAlib QRMA (streaming)
+ var qrmaStreaming = new global::QuanTAlib.Qrma(period);
+ var streamingResults = new List();
+ foreach (var item in _testData.Data)
+ {
+ streamingResults.Add(qrmaStreaming.Update(item).Value);
+ }
+
+ // Compare all records
+ Assert.Equal(batchResult.Count, streamingResults.Count);
+ for (int i = 0; i < batchResult.Count; i++)
+ {
+ Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-9);
+ }
+ }
+ _output.WriteLine("QRMA Batch(TSeries) vs Streaming validated successfully");
+ }
+
+ [Fact]
+ public void Validate_Span_Vs_Streaming()
+ {
+ int[] periods = { 5, 10, 14, 20, 50 };
+
+ foreach (var period in periods)
+ {
+ // Calculate QuanTAlib QRMA (Span API)
+ double[] qOutput = new double[_testData.RawData.Length];
+ global::QuanTAlib.Qrma.Batch(_testData.RawData.Span, qOutput.AsSpan(), period);
+
+ // Calculate QuanTAlib QRMA (streaming)
+ var qrmaStreaming = new global::QuanTAlib.Qrma(period);
+ var streamingResults = new List();
+ foreach (var item in _testData.Data)
+ {
+ streamingResults.Add(qrmaStreaming.Update(item).Value);
+ }
+
+ // Compare all records
+ for (int i = 0; i < qOutput.Length; i++)
+ {
+ Assert.Equal(streamingResults[i], qOutput[i], 1e-9);
+ }
+ }
+ _output.WriteLine("QRMA Span vs Streaming validated successfully");
+ }
+
+ [Fact]
+ public void Validate_Calculate_ReturnsHotIndicator()
+ {
+ int[] periods = { 5, 10, 14, 20 };
+
+ foreach (var period in periods)
+ {
+ var (results, indicator) = global::QuanTAlib.Qrma.Calculate(_testData.Data, period);
+
+ Assert.True(indicator.IsHot);
+ Assert.Equal(results.Count, _testData.Data.Count);
+ Assert.True(double.IsFinite(indicator.Last.Value));
+
+ // The hot indicator should continue to produce valid results
+ var nextResult = indicator.Update(new TValue(DateTime.UtcNow, 100.0));
+ Assert.True(double.IsFinite(nextResult.Value));
+ }
+ _output.WriteLine("QRMA Calculate returns hot indicator validated successfully");
+ }
+
+ [Fact]
+ public void Validate_LinearData_ExactFit()
+ {
+ // For linear data y = 2x + 5, quadratic regression should fit exactly
+ const int period = 14;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = 2.0 * i + 5.0;
+ }
+
+ global::QuanTAlib.Qrma.Batch(values, output, period);
+
+ // After warmup, should match perfectly (linear is subset of quadratic)
+ for (int i = period; i < count; i++)
+ {
+ Assert.Equal(values[i], output[i], 1e-6);
+ }
+ _output.WriteLine("QRMA linear data exact fit validated successfully");
+ }
+
+ [Fact]
+ public void Validate_QuadraticData_ExactFit()
+ {
+ // For quadratic data y = 0.5x² + x + 3, quadratic regression should fit exactly
+ const int period = 14;
+ const int count = 100;
+ var values = new double[count];
+ var output = new double[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = 0.5 * i * i + i + 3.0;
+ }
+
+ global::QuanTAlib.Qrma.Batch(values, output, period);
+
+ // After warmup, should match well (quadratic model fits quadratic data exactly)
+ for (int i = period; i < count; i++)
+ {
+ Assert.Equal(values[i], output[i], 1e-3);
+ }
+ _output.WriteLine("QRMA quadratic data exact fit validated successfully");
+ }
+}
diff --git a/lib/trends_FIR/qrma/Qrma.cs b/lib/trends_FIR/qrma/Qrma.cs
new file mode 100644
index 00000000..4cc1ee2e
--- /dev/null
+++ b/lib/trends_FIR/qrma/Qrma.cs
@@ -0,0 +1,397 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace QuanTAlib;
+
+///
+/// QRMA: Quadratic Regression Moving Average
+///
+///
+/// Fits a degree-2 polynomial y = a + b*x + c*x² to the most recent N bars via
+/// ordinary least squares, returns the fitted endpoint value at x = N-1 (newest bar).
+///
+/// Calculation: Accumulate Faulhaber power sums S0..S4 + 3 cross-products in O(N),
+/// solve 3×3 normal equations via Cramer's rule in O(1).
+/// X-indexing: x = 0 oldest, x = N-1 newest; evaluate at x = N-1.
+///
+/// Detailed documentation
+[SkipLocalsInit]
+public sealed class Qrma : AbstractBase
+{
+ private readonly int _period;
+ private readonly RingBuffer _buffer;
+ private readonly TValuePublishedHandler _handler;
+ private ITValuePublisher? _source;
+ private int _disposed;
+
+ [StructLayout(LayoutKind.Auto)]
+ private record struct State(double LastVal, double LastValidValue);
+ private State _state;
+ private State _p_state;
+
+ private bool _isNew;
+
+ public override bool IsHot => _buffer.IsFull;
+ public bool IsNew => _isNew;
+
+ ///
+ /// Creates QRMA with specified period.
+ ///
+ /// Lookback period (must be >= 3 for quadratic regression)
+ public Qrma(int period)
+ {
+ if (period < 3)
+ {
+ throw new ArgumentException("Period must be at least 3 for quadratic regression", nameof(period));
+ }
+
+ _period = period;
+ _buffer = new RingBuffer(period);
+ Name = $"Qrma({period})";
+ WarmupPeriod = period;
+ _handler = Handle;
+ _state.LastValidValue = double.NaN;
+ }
+
+ public Qrma(ITValuePublisher source, int period) : this(period)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ _source.Pub += _handler;
+ }
+
+ private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private double GetValidValue(double input)
+ {
+ if (double.IsFinite(input))
+ {
+ _state.LastValidValue = input;
+ return input;
+ }
+ return _state.LastValidValue;
+ }
+
+ ///
+ /// Solves the 3×3 normal equation system for quadratic polynomial regression
+ /// using Cramer's rule. Data is oldest-first: data[0] = oldest, data[N-1] = newest.
+ /// Returns the fitted value at x = N-1 (newest bar endpoint).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double SolveQuadratic(ReadOnlySpan data, int count)
+ {
+ // N = count; x goes 0..N-1 (oldest=0, newest=N-1)
+ double n = count;
+
+ // Faulhaber closed-form power sums (O(1))
+ double s1 = n * (n - 1.0) * 0.5; // Σx
+ double s2 = n * (n - 1.0) * (2.0 * n - 1.0) / 6.0; // Σx²
+ double s3 = s1 * s1; // Σx³ = [N(N-1)/2]²
+ double s4 = n * (n - 1.0) * (2.0 * n - 1.0) * Math.FusedMultiplyAdd(3.0 * n, n - 1.0, -1.0) / 30.0; // Σx⁴
+
+ // Cross-products in O(N)
+ double r0 = 0, r1 = 0, r2 = 0;
+ for (int i = 0; i < count; i++)
+ {
+ double v = data[i];
+ double x = (double)i;
+ double x2 = x * x;
+
+ r0 += v; // Σy
+ r1 = Math.FusedMultiplyAdd(x, v, r1); // Σxy
+ r2 = Math.FusedMultiplyAdd(x2, v, r2); // Σx²y
+ }
+
+ // 3×3 normal equations:
+ // [ N S1 S2 ] [a] [r0]
+ // [ S1 S2 S3 ] [b] = [r1]
+ // [ S2 S3 S4 ] [c] [r2]
+
+ // Cramer's rule: det of coefficient matrix
+ double det = Math.FusedMultiplyAdd(n, s2 * s4 - s3 * s3,
+ Math.FusedMultiplyAdd(-s1, s1 * s4 - s3 * s2,
+ s2 * (s1 * s3 - s2 * s2)));
+
+ if (Math.Abs(det) < 1e-20)
+ {
+ return double.NaN; // Singular — caller substitutes raw price
+ }
+
+ double invDet = 1.0 / det;
+
+ // det_a: replace column 0 with [r0, r1, r2]
+ double detA = Math.FusedMultiplyAdd(r0, s2 * s4 - s3 * s3,
+ Math.FusedMultiplyAdd(-s1, r1 * s4 - r2 * s3,
+ s2 * (r1 * s3 - r2 * s2)));
+
+ // det_b: replace column 1 with [r0, r1, r2]
+ double detB = Math.FusedMultiplyAdd(n, r1 * s4 - r2 * s3,
+ Math.FusedMultiplyAdd(-r0, s1 * s4 - s3 * s2,
+ s2 * (s1 * r2 - s2 * r1)));
+
+ // det_c: replace column 2 with [r0, r1, r2]
+ double detC = Math.FusedMultiplyAdd(n, s2 * r2 - s3 * r1,
+ Math.FusedMultiplyAdd(-s1, s1 * r2 - s2 * r1,
+ r0 * (s1 * s3 - s2 * s2)));
+
+ double a = detA * invDet;
+ double b = detB * invDet;
+ double c = detC * invDet;
+
+ // Evaluate at x = N-1 (newest bar endpoint)
+ double xEval = n - 1.0;
+ return Math.FusedMultiplyAdd(c, xEval * xEval, Math.FusedMultiplyAdd(b, xEval, a));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public override TValue Update(TValue input, bool isNew = true)
+ {
+ _isNew = isNew;
+ if (isNew)
+ {
+ _p_state = _state;
+ double val = GetValidValue(input.Value);
+ _buffer.Add(val);
+ _state.LastVal = val;
+ }
+ else
+ {
+ _state.LastValidValue = _p_state.LastValidValue;
+ double val = GetValidValue(input.Value);
+ _buffer.UpdateNewest(val);
+ _state.LastVal = val;
+ }
+
+ double result;
+ int count = _buffer.Count;
+ if (count < 3)
+ {
+ // Not enough points for quadratic regression — return current value
+ result = _buffer.Newest;
+ }
+ else
+ {
+ // Get buffer data in chronological order (oldest=index 0, newest=last)
+ // SolveQuadratic expects oldest-first: data[0]=oldest, data[N-1]=newest
+ const int StackAllocThreshold = 256;
+ double[]? rented = count > StackAllocThreshold ? ArrayPool.Shared.Rent(count) : null;
+ Span data = rented != null
+ ? rented.AsSpan(0, count)
+ : stackalloc double[count];
+
+ try
+ {
+ // Copy buffer in chronological order (oldest first) — direct from RingBuffer
+ var span = _buffer.GetSpan();
+ span[..count].CopyTo(data);
+
+ double solved = SolveQuadratic(data, count);
+ result = double.IsFinite(solved) ? solved : _buffer.Newest;
+ }
+ finally
+ {
+ if (rented != null)
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+ }
+
+ Last = new TValue(input.Time, result);
+ PubEvent(Last, isNew);
+ return Last;
+ }
+
+ public override TSeries Update(TSeries source)
+ {
+ if (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);
+
+ var tSpan = CollectionsMarshal.AsSpan(t);
+ var vSpan = CollectionsMarshal.AsSpan(v);
+
+ double initialLastValid = _state.LastValidValue;
+ Batch(source.Values, vSpan, _period, initialLastValid);
+ source.Times.CopyTo(tSpan);
+
+ // Restore state by replaying last 'period' bars
+ int windowSize = Math.Min(len, _period);
+ int startIndex = len - windowSize;
+
+ Reset();
+
+ if (startIndex > 0)
+ {
+ for (int i = startIndex - 1; i >= 0; i--)
+ {
+ if (double.IsFinite(source.Values[i]))
+ {
+ _state.LastValidValue = source.Values[i];
+ break;
+ }
+ }
+ }
+ else
+ {
+ _state.LastValidValue = initialLastValid;
+ }
+
+ for (int i = startIndex; i < len; i++)
+ {
+ double val = GetValidValue(source.Values[i]);
+ _buffer.Add(val);
+ _state.LastVal = val;
+ }
+ _p_state = _state;
+
+ Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
+ return new TSeries(t, v);
+ }
+
+ public override void Prime(ReadOnlySpan source, TimeSpan? step = null)
+ {
+ foreach (var value in source)
+ {
+ Update(new TValue(DateTime.MinValue, value));
+ }
+ }
+
+ public static TSeries Batch(TSeries source, int period)
+ {
+ var qrma = new Qrma(period);
+ return qrma.Update(source);
+ }
+
+ ///
+ /// Calculates QRMA in-place, writing results to pre-allocated output span.
+ /// Zero-allocation method for maximum performance.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Batch(ReadOnlySpan source, Span output, int period, double initialLastValid = double.NaN)
+ {
+ if (source.Length != output.Length)
+ {
+ throw new ArgumentException("Source and output must have the same length", nameof(output));
+ }
+
+ if (period < 3)
+ {
+ throw new ArgumentException("Period must be at least 3 for quadratic regression", nameof(period));
+ }
+
+ int len = source.Length;
+ if (len == 0)
+ {
+ return;
+ }
+
+ const int StackAllocThreshold = 256;
+
+ // Pre-process: build a NaN-corrected copy of source so we can index it directly
+ double[]? rentedClean = len > StackAllocThreshold ? ArrayPool.Shared.Rent(len) : null;
+ Span clean = rentedClean != null
+ ? rentedClean.AsSpan(0, len)
+ : stackalloc double[len];
+
+ double[]? rentedData = period > StackAllocThreshold ? ArrayPool.Shared.Rent(period) : null;
+ Span dataBuffer = rentedData != null
+ ? rentedData.AsSpan(0, period)
+ : stackalloc double[period];
+
+ try
+ {
+ double lastValid = initialLastValid;
+
+ // Build NaN-corrected array
+ for (int i = 0; i < len; i++)
+ {
+ double val = source[i];
+ if (double.IsFinite(val))
+ {
+ lastValid = val;
+ clean[i] = val;
+ }
+ else
+ {
+ clean[i] = lastValid;
+ }
+ }
+
+ // For each bar, solve quadratic regression over the window
+ for (int i = 0; i < len; i++)
+ {
+ int n = Math.Min(i + 1, period);
+ if (n < 3)
+ {
+ output[i] = clean[i];
+ }
+ else
+ {
+ // Build oldest-first data for SolveQuadratic
+ // data[0]=oldest (bar i-n+1), data[n-1]=newest (bar i)
+ Span data = dataBuffer[..n];
+ for (int j = 0; j < n; j++)
+ {
+ data[j] = clean[i - n + 1 + j];
+ }
+
+ double solved = SolveQuadratic(data, n);
+ output[i] = double.IsFinite(solved) ? solved : clean[i];
+ }
+ }
+ }
+ finally
+ {
+ if (rentedClean != null)
+ {
+ ArrayPool.Shared.Return(rentedClean);
+ }
+ if (rentedData != null)
+ {
+ ArrayPool.Shared.Return(rentedData);
+ }
+ }
+ }
+
+ public static (TSeries Results, Qrma Indicator) Calculate(TSeries source, int period)
+ {
+ var indicator = new Qrma(period);
+ TSeries results = indicator.Update(source);
+ return (results, indicator);
+ }
+
+ ///
+ /// Resets the QRMA state.
+ ///
+ public override void Reset()
+ {
+ _buffer.Clear();
+ _state = default;
+ _state.LastValidValue = double.NaN;
+ _p_state = default;
+ Last = default;
+ }
+
+ ///
+ /// Disposes the Qrma instance, unsubscribing from the source publisher if subscribed.
+ /// This method is idempotent and thread-safe.
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null)
+ {
+ _source.Pub -= _handler;
+ _source = null;
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/lib/trends_IIR/rain/Rain.md b/lib/trends_FIR/rain/Rain.md
similarity index 100%
rename from lib/trends_IIR/rain/Rain.md
rename to lib/trends_FIR/rain/Rain.md
diff --git a/lib/trends_IIR/rain/rain.pine b/lib/trends_FIR/rain/rain.pine
similarity index 100%
rename from lib/trends_IIR/rain/rain.pine
rename to lib/trends_FIR/rain/rain.pine
diff --git a/lib/trends_FIR/rwma/Rwma.Quantower.Tests.cs b/lib/trends_FIR/rwma/Rwma.Quantower.Tests.cs
new file mode 100644
index 00000000..605ca443
--- /dev/null
+++ b/lib/trends_FIR/rwma/Rwma.Quantower.Tests.cs
@@ -0,0 +1,214 @@
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib.Tests;
+
+public class RwmaIndicatorTests
+{
+ [Fact]
+ public void RwmaIndicator_Constructor_SetsDefaults()
+ {
+ var indicator = new RwmaIndicator();
+
+ Assert.Equal("RWMA - Range Weighted Moving Average", indicator.Name);
+ Assert.Equal(14, indicator.Period);
+ Assert.False(indicator.SeparateWindow);
+ Assert.True(indicator.OnBackGround);
+ Assert.Equal(14, indicator.MinHistoryDepths);
+ }
+
+ [Fact]
+ public void RwmaIndicator_ShortName_ReflectsPeriod()
+ {
+ var indicator = new RwmaIndicator { Period = 10 };
+ Assert.Equal("RWMA(10)", indicator.ShortName);
+
+ var indicatorDefault = new RwmaIndicator { Period = 14 };
+ Assert.Equal("RWMA(14)", indicatorDefault.ShortName);
+ }
+
+ [Fact]
+ public void RwmaIndicator_MinHistoryDepths_EqualsPeriod()
+ {
+ var indicator = new RwmaIndicator { Period = 10 };
+
+ Assert.Equal(10, indicator.MinHistoryDepths);
+ Assert.Equal(10, ((IWatchlistIndicator)indicator).MinHistoryDepths);
+ }
+
+ [Fact]
+ public void RwmaIndicator_Initialize_CreatesInternalRwma()
+ {
+ var indicator = new RwmaIndicator();
+
+ // Initialize should not throw
+ indicator.Initialize();
+
+ // After init, line series should exist
+ Assert.Single(indicator.LinesSeries);
+ }
+
+ [Fact]
+ public void RwmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
+ {
+ var indicator = new RwmaIndicator { Period = 5 };
+ indicator.Initialize();
+
+ // Add historical data
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 30; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000);
+
+ // Process update for each bar to simulate history loading
+ var args = new UpdateArgs(UpdateReason.HistoricalBar);
+ indicator.ProcessUpdate(args);
+ }
+
+ // Line series should have a value
+ double val = indicator.LinesSeries[0].GetValue(0);
+ Assert.True(double.IsFinite(val));
+ }
+
+ [Fact]
+ public void RwmaIndicator_ProcessUpdate_NewBar_ComputesValue()
+ {
+ var indicator = new RwmaIndicator { Period = 5 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 30; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000);
+ }
+
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ // Add new bar
+ indicator.HistoricalData.AddBar(now.AddMinutes(30), 130, 140, 120, 135, 1500);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
+
+ Assert.Equal(2, indicator.LinesSeries[0].Count);
+ }
+
+ [Fact]
+ public void RwmaIndicator_Value_TracksRangeWeightedAverage()
+ {
+ var indicator = new RwmaIndicator { Period = 10 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+ var recordedValues = new List();
+
+ for (int i = 0; i < 50; i++)
+ {
+ // Create varying price patterns with varying ranges
+ double open = 100 + i;
+ double high = open + 10 + (i % 5);
+ double low = open - 5;
+ double close = (i % 2 == 0) ? high - 1 : low + 1;
+
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ if (i > 0)
+ {
+ double val = indicator.LinesSeries[0].GetValue(0);
+ recordedValues.Add(val);
+ }
+ }
+
+ // RWMA should produce finite values
+ Assert.True(recordedValues.Count > 0, "Should have recorded values");
+ Assert.All(recordedValues, v => Assert.True(double.IsFinite(v)));
+
+ // RWMA values should be within price range (approximately)
+ double avgValue = recordedValues.Average();
+ Assert.True(avgValue > 90 && avgValue < 200, $"RWMA {avgValue} should be within reasonable price range");
+ }
+
+ [Fact]
+ public void RwmaIndicator_DifferentPeriods_ProduceDifferentResults()
+ {
+ var indicator5 = new RwmaIndicator { Period = 5 };
+ var indicator20 = new RwmaIndicator { Period = 20 };
+
+ indicator5.Initialize();
+ indicator20.Initialize();
+
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 50; i++)
+ {
+ double open = 100 + i;
+ double high = open + 10;
+ double low = open - 5;
+ double close = open + 5;
+
+ indicator5.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
+ indicator20.HistoricalData.AddBar(now.AddMinutes(i), open, high, low, close, 1000);
+
+ indicator5.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ indicator20.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ double val5 = indicator5.LinesSeries[0].GetValue(0);
+ double val20 = indicator20.LinesSeries[0].GetValue(0);
+
+ // Different periods should produce different results
+ // Shorter period responds faster to recent prices
+ Assert.NotEqual(val5, val20, 6);
+ }
+
+ [Fact]
+ public void RwmaIndicator_SlidingWindow_DropsOldValues()
+ {
+ var indicator = new RwmaIndicator { Period = 3 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ // Add initial bars with constant price
+ for (int i = 0; i < 3; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 100, 1000);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ double valueAtConstant = indicator.LinesSeries[0].GetValue(0);
+
+ // Add bars with higher prices - old low prices should drop out
+ for (int i = 3; i < 6; i++)
+ {
+ indicator.HistoricalData.AddBar(now.AddMinutes(i), 200, 210, 190, 200, 1000);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+ }
+
+ double valueAfterHigh = indicator.LinesSeries[0].GetValue(0);
+
+ // Value should have changed significantly as old bars dropped
+ Assert.True(valueAfterHigh > valueAtConstant + 50,
+ $"RWMA should increase as low-price bars drop out: {valueAtConstant} -> {valueAfterHigh}");
+ }
+
+ [Fact]
+ public void RwmaIndicator_VolatileBarsHaveMoreWeight()
+ {
+ var indicator = new RwmaIndicator { Period = 10 };
+ indicator.Initialize();
+
+ var now = DateTime.UtcNow;
+
+ // Add bars with varying ranges — volatile bar at close=50, quiet bar at close=150
+ // Volatile bar: range = 40
+ indicator.HistoricalData.AddBar(now, 50, 70, 30, 50, 1000);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ // Quiet bar: range = 2
+ indicator.HistoricalData.AddBar(now.AddMinutes(1), 150, 151, 149, 150, 1000);
+ indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
+
+ double val = indicator.LinesSeries[0].GetValue(0);
+
+ // RWMA should be close to 50 (the volatile bar) rather than 150
+ Assert.True(val < 60, $"RWMA {val} should be weighted toward volatile bar close (50)");
+ }
+}
diff --git a/lib/trends_FIR/rwma/Rwma.Quantower.cs b/lib/trends_FIR/rwma/Rwma.Quantower.cs
new file mode 100644
index 00000000..a4ddc050
--- /dev/null
+++ b/lib/trends_FIR/rwma/Rwma.Quantower.cs
@@ -0,0 +1,54 @@
+using System.Drawing;
+using System.Runtime.CompilerServices;
+using TradingPlatform.BusinessLayer;
+
+namespace QuanTAlib;
+
+///
+/// Quantower adapter for RWMA (Range Weighted Moving Average).
+///
+[SkipLocalsInit]
+public sealed class RwmaIndicator : Indicator, IWatchlistIndicator
+{
+ [InputParameter("Period", sortIndex: 10, 1, 10000, 1, 0)]
+ public int Period { get; set; } = 14;
+
+ [InputParameter("Show cold values", sortIndex: 21)]
+ public bool ShowColdValues { get; set; } = true;
+
+ private Rwma _rwma = null!;
+ private readonly LineSeries _series;
+
+ public int MinHistoryDepths => Period;
+ int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
+
+ public override string ShortName => $"RWMA({Period})";
+ public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_FIR/rwma/Rwma.Quantower.cs";
+
+ public RwmaIndicator()
+ {
+ OnBackGround = true;
+ SeparateWindow = false;
+ Name = "RWMA - Range Weighted Moving Average";
+ Description = "Range Weighted Moving Average weights each bar's close by its price range (high - low), giving greater influence to volatile bars.";
+
+ _series = new LineSeries(name: "RWMA", color: Color.Cyan, width: 2, style: LineStyle.Solid);
+ AddLineSeries(_series);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnInit()
+ {
+ _rwma = new Rwma(Period);
+ base.OnInit();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected override void OnUpdate(UpdateArgs args)
+ {
+ TBar bar = this.GetInputBar(args);
+ TValue result = _rwma.Update(bar, args.IsNewBar());
+
+ _series.SetValue(result.Value, _rwma.IsHot, ShowColdValues);
+ }
+}
diff --git a/lib/trends_FIR/rwma/Rwma.Tests.cs b/lib/trends_FIR/rwma/Rwma.Tests.cs
new file mode 100644
index 00000000..401909a1
--- /dev/null
+++ b/lib/trends_FIR/rwma/Rwma.Tests.cs
@@ -0,0 +1,761 @@
+namespace QuanTAlib.Tests;
+
+public class RwmaTests
+{
+ private readonly GBM _feed;
+ private readonly TBarSeries _bars;
+
+ public RwmaTests()
+ {
+ _feed = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
+ _bars = new TBarSeries();
+ for (int i = 0; i < 1000; i++)
+ {
+ _bars.Add(_feed.Next());
+ }
+ }
+
+ // ============ A) Constructor Validation ============
+
+ [Fact]
+ public void Constructor_DefaultPeriod_ShouldBe14()
+ {
+ var rwma = new Rwma();
+ Assert.Equal("Rwma(14)", rwma.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithPeriod_ShouldSetName()
+ {
+ var rwma = new Rwma(10);
+ Assert.Equal("Rwma(10)", rwma.Name);
+ }
+
+ [Fact]
+ public void Constructor_ZeroPeriod_ShouldThrow()
+ {
+ var ex = Assert.Throws(() => new Rwma(0));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_NegativePeriod_ShouldThrow()
+ {
+ var ex = Assert.Throws(() => new Rwma(-1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_Period1_ShouldNotThrow()
+ {
+ var rwma = new Rwma(1);
+ Assert.Equal("Rwma(1)", rwma.Name);
+ }
+
+ // ============ B) Basic Calculation ============
+
+ [Fact]
+ public void Update_ReturnsValidTValue()
+ {
+ var rwma = new Rwma(10);
+ var bar = _bars[0];
+ var result = rwma.Update(bar);
+
+ Assert.NotEqual(default, result);
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_FirstBar_ShouldBeClosePrice()
+ {
+ var rwma = new Rwma(10);
+ var bar = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
+ var result = rwma.Update(bar);
+
+ // RWMA of first bar: range=15-8=7, sumCR=12*7=84, sumR=7, RWMA=84/7=12
+ Assert.Equal(12.0, result.Value, 10);
+ }
+
+ [Fact]
+ public void Update_MultipleBarsSamePrice_ShouldReturnSameRwma()
+ {
+ var rwma = new Rwma(10);
+ // All bars have same close and same range
+ var bar1 = new TBar(DateTime.UtcNow, 95, 105, 95, 100, 100);
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 95, 105, 95, 100, 200);
+ var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 95, 105, 95, 100, 300);
+
+ rwma.Update(bar1);
+ rwma.Update(bar2);
+ var result = rwma.Update(bar3);
+
+ // All closes = 100, all ranges = 10, so RWMA = (100*10 + 100*10 + 100*10) / (10+10+10) = 100
+ Assert.Equal(100.0, result.Value, 10);
+ }
+
+ [Fact]
+ public void Update_RangeWeighting_Works()
+ {
+ var rwma = new Rwma(10);
+ // Bar 1: close=10, range=2 (high=11, low=9)
+ // Bar 2: close=20, range=6 (high=23, low=17)
+ // RWMA = (10*2 + 20*6) / (2+6) = (20 + 120) / 8 = 17.5
+ var bar1 = new TBar(DateTime.UtcNow, 10, 11, 9, 10, 100);
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 23, 17, 20, 100);
+
+ rwma.Update(bar1);
+ var result = rwma.Update(bar2);
+
+ Assert.Equal(17.5, result.Value, 10);
+ }
+
+ [Fact]
+ public void Update_HighRangeBar_HasMoreInfluence()
+ {
+ var rwma = new Rwma(10);
+
+ // Bar 1: close=10, high range (range=20)
+ var bar1 = new TBar(DateTime.UtcNow, 10, 20, 0, 10, 100);
+ // Bar 2: close=20, low range (range=2)
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 21, 19, 20, 100);
+
+ rwma.Update(bar1);
+ var result = rwma.Update(bar2);
+
+ // RWMA = (10*20 + 20*2) / (20+2) = (200+40)/22 = 10.909...
+ double expected = (10.0 * 20.0 + 20.0 * 2.0) / (20.0 + 2.0);
+ Assert.Equal(expected, result.Value, 10);
+
+ // Should be closer to 10 (the high-range bar) than 20
+ Assert.True(result.Value < 15, "RWMA should be weighted toward high-range bar's close");
+ }
+
+ [Fact]
+ public void Update_SlidingWindow_ShouldDropOldValues()
+ {
+ var rwma = new Rwma(2);
+ // Period = 2, so only last 2 bars count
+
+ // Bar 1: close=10, range=4 (h=12, l=8)
+ var bar1 = new TBar(DateTime.UtcNow, 10, 12, 8, 10, 100);
+ rwma.Update(bar1);
+
+ // Bar 2: close=20, range=4 (h=22, l=18)
+ // RWMA = (10*4 + 20*4) / (4+4) = 120/8 = 15
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 22, 18, 20, 100);
+ rwma.Update(bar2);
+ Assert.Equal(15.0, rwma.Last.Value, 10);
+
+ // Bar 3: close=30, range=4 (h=32, l=28)
+ // Now bar1 drops out: RWMA = (20*4 + 30*4) / (4+4) = 200/8 = 25
+ var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 30, 32, 28, 30, 100);
+ var result = rwma.Update(bar3);
+
+ Assert.Equal(25.0, result.Value, 10);
+ }
+
+ [Fact]
+ public void Update_ZeroRange_DegeneratesToClose()
+ {
+ var rwma = new Rwma(10);
+ // All bars have zero range (high == low == close)
+ var bar1 = new TBar(DateTime.UtcNow, 50, 50, 50, 50, 100);
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 60, 60, 60, 60, 100);
+ var bar3 = new TBar(DateTime.UtcNow.AddMinutes(2), 70, 70, 70, 70, 100);
+
+ rwma.Update(bar1);
+ rwma.Update(bar2);
+ var result = rwma.Update(bar3);
+
+ // All ranges = 0, so RWMA degenerates to current close = 70
+ Assert.Equal(70.0, result.Value, 10);
+ }
+
+ // ============ C) State + Bar Correction (isNew) ============
+
+ [Fact]
+ public void IsHot_AfterPeriodBars_ShouldBeTrue()
+ {
+ var rwma = new Rwma(10);
+ Assert.False(rwma.IsHot);
+
+ for (int i = 0; i < 9; i++)
+ {
+ rwma.Update(_bars[i]);
+ Assert.False(rwma.IsHot);
+ }
+
+ rwma.Update(_bars[9]);
+ Assert.True(rwma.IsHot);
+ }
+
+ [Fact]
+ public void WarmupPeriod_ShouldMatchPeriod()
+ {
+ var rwma = new Rwma(14);
+ Assert.Equal(14, rwma.WarmupPeriod);
+ }
+
+ [Fact]
+ public void Update_IsNewTrue_ShouldAdvanceState()
+ {
+ var rwma = new Rwma(10);
+ var bar1 = new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100);
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 25, 15, 20, 100);
+
+ rwma.Update(bar1, isNew: true);
+ var result1 = rwma.Last.Value;
+
+ rwma.Update(bar2, isNew: true);
+ var result2 = rwma.Last.Value;
+
+ Assert.NotEqual(result1, result2);
+ }
+
+ [Fact]
+ public void Update_IsNewFalse_ShouldRollback()
+ {
+ var rwma = new Rwma(10);
+ var bar1 = new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100);
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 20, 25, 15, 20, 100);
+ var bar2Updated = new TBar(DateTime.UtcNow.AddMinutes(1), 15, 18, 12, 15, 100);
+
+ rwma.Update(bar1, isNew: true);
+ rwma.Update(bar2, isNew: true);
+ var afterBar2 = rwma.Last.Value;
+
+ // Correct bar2 with updated values
+ rwma.Update(bar2Updated, isNew: false);
+ var afterCorrection = rwma.Last.Value;
+
+ Assert.NotEqual(afterBar2, afterCorrection);
+ }
+
+ [Fact]
+ public void Update_IterativeCorrections_ShouldRestoreState()
+ {
+ var rwma = new Rwma(10);
+
+ // Process first 10 bars
+ for (int i = 0; i < 10; i++)
+ {
+ rwma.Update(_bars[i], isNew: true);
+ }
+ _ = rwma.Last.Value;
+
+ // Process bar 11
+ rwma.Update(_bars[10], isNew: true);
+ var valueAfter11 = rwma.Last.Value;
+
+ // Correct bar 11 multiple times with same data
+ for (int i = 0; i < 5; i++)
+ {
+ rwma.Update(_bars[10], isNew: false);
+ }
+ var valueAfterCorrections = rwma.Last.Value;
+
+ // Should get same result as after first processing of bar 11
+ Assert.Equal(valueAfter11, valueAfterCorrections, 10);
+ }
+
+ [Fact]
+ public void Reset_ShouldClearState()
+ {
+ var rwma = new Rwma(10);
+
+ for (int i = 0; i < 100; i++)
+ {
+ rwma.Update(_bars[i]);
+ }
+ Assert.True(rwma.IsHot);
+
+ rwma.Reset();
+
+ Assert.False(rwma.IsHot);
+ Assert.Equal(default, rwma.Last);
+ }
+
+ // ============ D) Warmup/Convergence ============
+
+ [Fact]
+ public void IsHot_FlipsExactlyAtPeriod()
+ {
+ var rwma = new Rwma(5);
+
+ for (int i = 0; i < 4; i++)
+ {
+ rwma.Update(_bars[i]);
+ Assert.False(rwma.IsHot, $"IsHot should be false at bar {i}");
+ }
+
+ rwma.Update(_bars[4]);
+ Assert.True(rwma.IsHot, "IsHot should be true at bar 4 (5th bar)");
+ }
+
+ [Fact]
+ public void WarmupPeriod_DependsOnPeriod()
+ {
+ Assert.Equal(5, new Rwma(5).WarmupPeriod);
+ Assert.Equal(20, new Rwma(20).WarmupPeriod);
+ Assert.Equal(100, new Rwma(100).WarmupPeriod);
+ }
+
+ // ============ E) Robustness (NaN/Infinity) ============
+
+ [Fact]
+ public void Update_NaN_ShouldUseLastValidValue()
+ {
+ var rwma = new Rwma(10);
+
+ // First bar establishes valid values
+ var bar1 = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
+ rwma.Update(bar1);
+
+ // Second bar with NaN should use last valid
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
+ var result = rwma.Update(bar2);
+
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_Infinity_ShouldUseLastValidValue()
+ {
+ var rwma = new Rwma(10);
+
+ var bar1 = new TBar(DateTime.UtcNow, 10, 15, 8, 12, 1000);
+ rwma.Update(bar1);
+
+ var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
+ var result = rwma.Update(bar2);
+
+ Assert.True(double.IsFinite(result.Value));
+ }
+
+ [Fact]
+ public void Update_BatchNaN_ShouldRemainFinite()
+ {
+ var rwma = new Rwma(10);
+
+ // Establish valid state
+ for (int i = 0; i < 20; i++)
+ {
+ rwma.Update(_bars[i]);
+ }
+
+ // Send multiple NaN bars
+ for (int i = 0; i < 5; i++)
+ {
+ var nanBar = new TBar(DateTime.UtcNow.AddMinutes(20 + i),
+ double.NaN, double.NaN, double.NaN, double.NaN, double.NaN);
+ var result = rwma.Update(nanBar);
+ Assert.True(double.IsFinite(result.Value), $"NaN bar {i} produced non-finite result");
+ }
+ }
+
+ // ============ F) Consistency (4 API modes) ============
+
+ [Fact]
+ public void Streaming_ShouldMatchBatch()
+ {
+ int period = 14;
+
+ // Streaming
+ var rwma = new Rwma(period);
+ var streamingResults = new List();
+ foreach (var bar in _bars)
+ {
+ streamingResults.Add(rwma.Update(bar).Value);
+ }
+
+ // Batch
+ var batchResult = Rwma.Batch(_bars, period);
+
+ // Compare last 100 values
+ for (int i = _bars.Count - 100; i < _bars.Count; i++)
+ {
+ Assert.Equal(batchResult.Values[i], streamingResults[i], 10);
+ }
+ }
+
+ [Fact]
+ public void Batch_TBarSeries_ShouldMatchSpan()
+ {
+ int period = 14;
+
+ var batchResult = Rwma.Batch(_bars, period);
+
+ var close = _bars.Close.Values.ToArray();
+ var high = _bars.High.Values.ToArray();
+ var low = _bars.Low.Values.ToArray();
+ var spanOutput = new double[_bars.Count];
+ Rwma.Batch(close, high, low, spanOutput, period);
+
+ for (int i = 0; i < _bars.Count; i++)
+ {
+ Assert.Equal(batchResult.Values[i], spanOutput[i], 12);
+ }
+ }
+
+ [Fact]
+ public void Eventing_ShouldMatchStreaming()
+ {
+ int period = 14;
+
+ // Streaming
+ var rwma1 = new Rwma(period);
+ var streamingResults = new List();
+ foreach (var bar in _bars)
+ {
+ streamingResults.Add(rwma1.Update(bar).Value);
+ }
+
+ // Event-based
+ var rwma2 = new Rwma(period);
+ var eventResults = new List();
+ rwma2.Pub += (object? sender, in TValueEventArgs args) => eventResults.Add(args.Value.Value);
+ foreach (var bar in _bars)
+ {
+ rwma2.Update(bar);
+ }
+
+ Assert.Equal(streamingResults.Count, eventResults.Count);
+ for (int i = 0; i < streamingResults.Count; i++)
+ {
+ Assert.Equal(streamingResults[i], eventResults[i], 12);
+ }
+ }
+
+ // ============ G) Span API Tests ============
+
+ [Fact]
+ public void Batch_Span_MismatchedLengths_ShouldThrow()
+ {
+ var close = new double[100];
+ var high = new double[99]; // Mismatched
+ var low = new double[100];
+ var output = new double[100];
+
+ var ex = Assert.Throws(() => Rwma.Batch(close, high, low, output, 10));
+ Assert.Equal("high", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_OutputLengthMismatch_ShouldThrow()
+ {
+ var close = new double[100];
+ var high = new double[100];
+ var low = new double[100];
+ var output = new double[50]; // Mismatched
+
+ var ex = Assert.Throws(() => Rwma.Batch(close, high, low, output, 10));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_ZeroPeriod_ShouldThrow()
+ {
+ var close = new double[100];
+ var high = new double[100];
+ var low = new double[100];
+ var output = new double[100];
+
+ var ex = Assert.Throws(() => Rwma.Batch(close, high, low, output, 0));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_NegativePeriod_ShouldThrow()
+ {
+ var close = new double[100];
+ var high = new double[100];
+ var low = new double[100];
+ var output = new double[100];
+
+ var ex = Assert.Throws(() => Rwma.Batch(close, high, low, output, -1));
+ Assert.Equal("period", ex.ParamName);
+ }
+
+ [Fact]
+ public void Batch_Span_NaN_ShouldNotPropagate()
+ {
+ var close = new double[] { 10, 20, double.NaN, 40, 50 };
+ var high = new double[] { 15, 25, double.NaN, 45, 55 };
+ var low = new double[] { 5, 15, double.NaN, 35, 45 };
+ var output = new double[5];
+
+ Rwma.Batch(close, high, low, output, 3);
+
+ for (int i = 0; i < output.Length; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite: {output[i]}");
+ }
+ }
+
+ [Fact]
+ public void Batch_Span_LargeData_ShouldNotOverflow()
+ {
+ // Test with period > StackallocThreshold (256)
+ int period = 300;
+ int len = 500;
+ var close = new double[len];
+ var high = new double[len];
+ var low = new double[len];
+ var output = new double[len];
+
+ for (int i = 0; i < len; i++)
+ {
+ close[i] = 100 + i;
+ high[i] = 100 + i + 5;
+ low[i] = 100 + i - 5;
+ }
+
+ Rwma.Batch(close, high, low, output, period);
+
+ for (int i = 0; i < len; i++)
+ {
+ Assert.True(double.IsFinite(output[i]), $"Output at index {i} is not finite");
+ }
+ }
+
+ // ============ H) Chainability / Events ============
+
+ [Fact]
+ public void Pub_ShouldFireOnUpdate()
+ {
+ var rwma = new Rwma(10);
+ int eventCount = 0;
+
+ rwma.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
+
+ rwma.Update(_bars[0]);
+ rwma.Update(_bars[1]);
+
+ Assert.Equal(2, eventCount);
+ }
+
+ [Fact]
+ public void Pub_EventArgs_ShouldContainCorrectValue()
+ {
+ var rwma = new Rwma(10);
+ TValue? lastEventValue = null;
+
+ rwma.Pub += (object? sender, in TValueEventArgs args) => lastEventValue = args.Value;
+
+ var result = rwma.Update(_bars[0]);
+ Assert.NotNull(lastEventValue);
+ Assert.Equal(result.Value, lastEventValue.Value.Value, 12);
+ }
+
+ // ============ TValue Input Tests ============
+
+ [Fact]
+ public void Update_TValue_ShouldWork()
+ {
+ var rwma = new Rwma(10);
+ var input = new TValue(DateTime.UtcNow, 100.0);
+ var result = rwma.Update(input);
+
+ // With TValue, high=low=close → range=0, degenerates to close
+ Assert.Equal(100.0, result.Value, 10);
+ }
+
+ [Fact]
+ public void Update_TValue_MultipleInputs_DegeneratesToClose()
+ {
+ var rwma = new Rwma(10);
+
+ // TValue input: range always 0, so always degenerates to current close
+ rwma.Update(new TValue(DateTime.UtcNow, 100.0));
+ var result = rwma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 200.0));
+
+ // All ranges 0 → fallback to current close = 200
+ Assert.Equal(200.0, result.Value, 10);
+ }
+
+ // ============ Batch/Series Tests ============
+
+ [Fact]
+ public void Update_TBarSeries_ShouldReturnTSeries()
+ {
+ var rwma = new Rwma(10);
+ var result = rwma.Update(_bars);
+
+ Assert.NotNull(result);
+ Assert.Equal(_bars.Count, result.Count);
+ }
+
+ [Fact]
+ public void Batch_Static_ShouldReturnTSeries()
+ {
+ var result = Rwma.Batch(_bars, 10);
+
+ Assert.NotNull(result);
+ Assert.Equal(_bars.Count, result.Count);
+ }
+
+ [Fact]
+ public void Batch_Static_WithDifferentPeriods_ShouldWork()
+ {
+ var result14 = Rwma.Batch(_bars, 14);
+ var result50 = Rwma.Batch(_bars, 50);
+
+ Assert.NotNull(result14);
+ Assert.NotNull(result50);
+ Assert.Equal(_bars.Count, result14.Count);
+ Assert.Equal(_bars.Count, result50.Count);
+ }
+
+ // ============ TSeries Calculate Tests ============
+
+ [Fact]
+ public void Calculate_Static_ShouldReturnTSeriesAndIndicator()
+ {
+ var (results, indicator) = Rwma.Calculate(_bars, 14);
+
+ Assert.NotNull(results);
+ Assert.Equal(_bars.Count, results.Count);
+ Assert.True(indicator.IsHot);
+ }
+
+ [Fact]
+ public void Batch_TSeries_ShouldWork()
+ {
+ var sourceSeries = _bars.Close;
+ var result = Rwma.Batch(sourceSeries, 20);
+
+ Assert.NotNull(result);
+ Assert.Equal(sourceSeries.Count, result.Count);
+ }
+
+ // ============ Prime Tests ============
+
+ [Fact]
+ public void Prime_ShouldInitializeState()
+ {
+ var rwma = new Rwma(10);
+ rwma.Prime(_bars);
+
+ Assert.True(rwma.IsHot);
+ Assert.True(double.IsFinite(rwma.Last.Value));
+ }
+
+ [Fact]
+ public void Prime_ThenUpdate_ShouldContinueCorrectly()
+ {
+ var rwma1 = new Rwma(10);
+ var rwma2 = new Rwma(10);
+
+ // rwma1: process all bars
+ for (int i = 0; i < 100; i++)
+ {
+ rwma1.Update(_bars[i]);
+ }
+
+ // rwma2: prime with first 50, then stream remaining
+ var primeBars = new TBarSeries();
+ for (int i = 0; i < 50; i++)
+ {
+ primeBars.Add(_bars[i]);
+ }
+ rwma2.Prime(primeBars);
+ for (int i = 50; i < 100; i++)
+ {
+ rwma2.Update(_bars[i]);
+ }
+
+ // Both should produce the same result
+ Assert.Equal(rwma1.Last.Value, rwma2.Last.Value, 10);
+ }
+
+ // ============ Algorithm-Specific Tests ============
+
+ [Fact]
+ public void RangeWeighting_VolatileBarHasMoreWeight()
+ {
+ var rwma = new Rwma(10);
+
+ // Bar with large range (volatile) at close=50
+ var volatileBar = new TBar(DateTime.UtcNow, 50, 70, 30, 50, 100); // range=40
+ // Bar with small range (quiet) at close=100
+ var quietBar = new TBar(DateTime.UtcNow.AddMinutes(1), 100, 101, 99, 100, 100); // range=2
+
+ rwma.Update(volatileBar);
+ var result = rwma.Update(quietBar);
+
+ // RWMA = (50*40 + 100*2) / (40+2) = (2000+200)/42 = 52.38...
+ double expected = (50.0 * 40.0 + 100.0 * 2.0) / 42.0;
+ Assert.Equal(expected, result.Value, 10);
+
+ // Should be much closer to 50 than 100
+ Assert.True(result.Value < 60, "RWMA should strongly lean toward the volatile bar's close");
+ }
+
+ [Fact]
+ public void StablePrice_ConstantRange_ShouldReturnSma()
+ {
+ var rwma = new Rwma(5);
+
+ // When all bars have the same range, RWMA reduces to SMA of closes
+ // because weights are all equal
+ var now = DateTime.UtcNow;
+ double[] closes = { 10, 20, 30, 40, 50 };
+
+ for (int i = 0; i < 5; i++)
+ {
+ // All bars have range = 10
+ var bar = new TBar(now.AddMinutes(i), closes[i], closes[i] + 5, closes[i] - 5, closes[i], 100);
+ rwma.Update(bar);
+ }
+
+ // When all ranges equal, RWMA = SMA = (10+20+30+40+50)/5 = 30
+ Assert.Equal(30.0, rwma.Last.Value, 10);
+ }
+
+ [Fact]
+ public void Period1_ShouldReturnClose()
+ {
+ var rwma = new Rwma(1);
+
+ var bar = new TBar(DateTime.UtcNow, 50, 60, 40, 55, 100);
+ var result = rwma.Update(bar);
+
+ // Period 1: only current bar, RWMA = close * range / range = close
+ Assert.Equal(55.0, result.Value, 10);
+ }
+
+ [Fact]
+ public void ConvexCombination_NeverExceedsPriceRange()
+ {
+ var rwma = new Rwma(20);
+ var results = new List();
+
+ for (int i = 0; i < 100; i++)
+ {
+ results.Add(rwma.Update(_bars[i]).Value);
+ }
+
+ // Find min/max close in last 20 bars for the last few results
+ for (int i = 80; i < 100; i++)
+ {
+ double minClose = double.MaxValue;
+ double maxClose = double.MinValue;
+ for (int j = i - 19; j <= i; j++)
+ {
+ double c = _bars[j].Close;
+ if (c < minClose)
+ {
+ minClose = c;
+ }
+ if (c > maxClose)
+ {
+ maxClose = c;
+ }
+ }
+
+ // RWMA is a convex combination — should be within [minClose, maxClose]
+ Assert.True(results[i] >= minClose - 1e-9 && results[i] <= maxClose + 1e-9,
+ $"RWMA at {i} ({results[i]}) should be within [{minClose}, {maxClose}]");
+ }
+ }
+}
diff --git a/lib/trends_FIR/rwma/Rwma.Validation.Tests.cs b/lib/trends_FIR/rwma/Rwma.Validation.Tests.cs
new file mode 100644
index 00000000..f970bb4e
--- /dev/null
+++ b/lib/trends_FIR/rwma/Rwma.Validation.Tests.cs
@@ -0,0 +1,314 @@
+namespace QuanTAlib.Tests;
+
+public class RwmaValidationTests
+{
+ private readonly ValidationTestData _data;
+
+ public RwmaValidationTests()
+ {
+ _data = new ValidationTestData();
+ }
+
+ // ============ External Library Validation ============
+ // RWMA is not available in Skender, TA-Lib, Tulip, or Ooples.
+ // Validation focuses on internal consistency and algorithm correctness.
+
+ [Fact]
+ public void Rwma_NotAvailable_Skender()
+ {
+ Assert.True(true, "RWMA is not available in Skender.Stock.Indicators");
+ }
+
+ [Fact]
+ public void Rwma_NotAvailable_TaLib()
+ {
+ Assert.True(true, "RWMA is not available in TA-Lib");
+ }
+
+ [Fact]
+ public void Rwma_NotAvailable_Tulip()
+ {
+ Assert.True(true, "RWMA is not available in Tulip");
+ }
+
+ [Fact]
+ public void Rwma_NotAvailable_Ooples()
+ {
+ Assert.True(true, "RWMA is not available in OoplesFinance");
+ }
+
+ // ============ Internal Consistency Tests ============
+
+ [Fact]
+ public void Rwma_Streaming_Matches_Batch()
+ {
+ int period = 14;
+
+ // Streaming
+ var rwma = new Rwma(period);
+ var streamingValues = new List();
+ foreach (var bar in _data.Bars)
+ {
+ streamingValues.Add(rwma.Update(bar).Value);
+ }
+
+ // Batch
+ var batchResult = Rwma.Batch(_data.Bars, period);
+ var batchValues = batchResult.Values.ToArray();
+
+ ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-10);
+ }
+
+ [Fact]
+ public void Rwma_Span_Matches_Streaming()
+ {
+ int period = 14;
+
+ // Streaming
+ var rwma = new Rwma(period);
+ var streamingValues = new List();
+ foreach (var bar in _data.Bars)
+ {
+ streamingValues.Add(rwma.Update(bar).Value);
+ }
+
+ // Span
+ var close = _data.Bars.Close.Values.ToArray();
+ var high = _data.Bars.High.Values.ToArray();
+ var low = _data.Bars.Low.Values.ToArray();
+ var spanValues = new double[close.Length];
+ Rwma.Batch(close, high, low, spanValues, period);
+
+ ValidationHelper.VerifyData(streamingValues.ToArray(), spanValues, 0, 100, 1e-10);
+ }
+
+ [Fact]
+ public void Rwma_Batch_Matches_Span()
+ {
+ int period = 14;
+
+ // Batch
+ var batchResult = Rwma.Batch(_data.Bars, period);
+ var batchValues = batchResult.Values.ToArray();
+
+ // Span
+ var close = _data.Bars.Close.Values.ToArray();
+ var high = _data.Bars.High.Values.ToArray();
+ var low = _data.Bars.Low.Values.ToArray();
+ var spanValues = new double[close.Length];
+ Rwma.Batch(close, high, low, spanValues, period);
+
+ // Batch and Span use identical code path, should match exactly
+ ValidationHelper.VerifyData(batchValues, spanValues, 0, 100, 1e-12);
+ }
+
+ // ============ Algorithm Correctness Tests ============
+
+ [Fact]
+ public void Rwma_Algorithm_Correctness_ManualCalculation()
+ {
+ // Manual calculation to verify algorithm correctness
+ var bars = new TBarSeries();
+
+ // Bar 0: close=10, high=15, low=5 → range=10
+ // Bar 1: close=20, high=24, low=18 → range=6
+ // Bar 2: close=30, high=35, low=25 → range=10
+ bars.Add(new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100));
+ bars.Add(new TBar(DateTime.UtcNow.AddMinutes(1), 20, 24, 18, 20, 100));
+ bars.Add(new TBar(DateTime.UtcNow.AddMinutes(2), 30, 35, 25, 30, 100));
+
+ var rwma = new Rwma(10); // Period larger than data
+ var results = new List();
+ foreach (var bar in bars)
+ {
+ results.Add(rwma.Update(bar).Value);
+ }
+
+ // Bar 0: RWMA = 10*10 / 10 = 10
+ Assert.Equal(10.0, results[0], 6);
+
+ // Bar 1: RWMA = (10*10 + 20*6) / (10+6) = (100+120)/16 = 13.75
+ double expectedBar1 = (10.0 * 10.0 + 20.0 * 6.0) / 16.0;
+ Assert.Equal(expectedBar1, results[1], 6);
+
+ // Bar 2: RWMA = (10*10 + 20*6 + 30*10) / (10+6+10) = (100+120+300)/26 = 20.0
+ double expectedBar2 = (10.0 * 10.0 + 20.0 * 6.0 + 30.0 * 10.0) / 26.0;
+ Assert.Equal(expectedBar2, results[2], 6);
+ }
+
+ [Fact]
+ public void Rwma_Algorithm_Correctness_SlidingWindow()
+ {
+ // Verify sliding window drops old values correctly
+ var rwma = new Rwma(2); // Period = 2
+
+ // Bar 0: close=10, range=10 (h=15, l=5)
+ rwma.Update(new TBar(DateTime.UtcNow, 10, 15, 5, 10, 100));
+ Assert.Equal(10.0, rwma.Last.Value, 6);
+
+ // Bar 1: close=20, range=6 (h=23, l=17)
+ // RWMA = (10*10 + 20*6) / (10+6) = 220/16 = 13.75
+ rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 20, 23, 17, 20, 100));
+ Assert.Equal(13.75, rwma.Last.Value, 6);
+
+ // Bar 2: close=30, range=10 (h=35, l=25)
+ // Now bar0 drops out: RWMA = (20*6 + 30*10) / (6+10) = (120+300)/16 = 26.25
+ rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(2), 30, 35, 25, 30, 100));
+ Assert.Equal(26.25, rwma.Last.Value, 6);
+ }
+
+ [Fact]
+ public void Rwma_Algorithm_Correctness_RangeWeighting()
+ {
+ // Verify range weighting: high-range bars have more influence
+ var rwma = new Rwma(10);
+
+ // Two bars: one with high range at low price, one with low range at high price
+ rwma.Update(new TBar(DateTime.UtcNow, 10, 60, 10, 10, 100)); // range=50
+ var result = rwma.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 100, 101, 99, 100, 100)); // range=2
+
+ // RWMA = (10*50 + 100*2) / (50+2) = (500+200)/52 = 13.46...
+ double expected = (10.0 * 50.0 + 100.0 * 2.0) / 52.0;
+ Assert.Equal(expected, result.Value, 6);
+
+ // RWMA should be much closer to 10 than to 100
+ Assert.True(result.Value < 20, "RWMA should be weighted toward high-range price");
+ }
+
+ [Fact]
+ public void Rwma_DifferentPeriods_ProduceDifferentResults()
+ {
+ var rwma10 = new Rwma(10);
+ var rwma20 = new Rwma(20);
+ var rwma50 = new Rwma(50);
+
+ var results10 = new List();
+ var results20 = new List();
+ var results50 = new List