Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+1 -1
View File
@@ -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
+10 -1
View File
@@ -88,6 +88,14 @@
</AdditionalFiles>
</ItemGroup>
<!-- GitVersion: skip during design-time builds and restore to prevent VS Code/OmniSharp hangs.
GitVersion scans entire git history on every MSBuild evaluation; with 50+ untracked files
and VS Code's C# extension triggering continuous design-time builds, this causes infinite
process accumulation. Only run GitVersion during actual CLI builds. -->
<PropertyGroup Condition="'$(DesignTimeBuild)' == 'true' OR '$(BuildingProject)' != 'true'">
<GitVersionSkip>true</GitVersionSkip>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageReference Include="GitVersion.MsBuild" Version="6.5.1">
@@ -108,9 +116,10 @@
</PackageReference>
</ItemGroup>
<!-- Quantower SDK: local deployment path (override with QuantowerRoot env var).
QuantowerPath removed - was dead code running GetDirectories on every MSBuild evaluation. -->
<PropertyGroup Condition="'$(IsLocalBuild)' == 'true' AND $([MSBuild]::IsOSPlatform('Windows'))">
<QuantowerRoot Condition="'$(QuantowerRoot)' == ''">Z:\Quantower</QuantowerRoot>
<QuantowerPath Condition="Exists('$(QuantowerRoot)\TradingPlatform')">$([System.IO.Directory]::GetDirectories("$(QuantowerRoot)\TradingPlatform", "v1*")[0])</QuantowerPath>
</PropertyGroup>
</Project>
+13 -4
View File
@@ -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)
+13 -4
View File
@@ -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 |
+6 -1
View File
@@ -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 | - | - | - | - |
+23 -23
View File
@@ -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) |
+29
View File
@@ -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<double>` |
| 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)
+30
View File
@@ -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<double>` (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)
+26
View File
@@ -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)
+40
View File
@@ -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)
+42
View File
@@ -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)
+32
View File
@@ -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)
+28
View File
@@ -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<double>` |
| 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)
+33
View File
@@ -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)
+28
View File
@@ -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)
+33
View File
@@ -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)
+42
View File
@@ -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)
+40
View File
@@ -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<double>` 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)
+26
View File
@@ -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).
+28
View File
@@ -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<double>` |
| Memory layout | Two circular buffers + two deques; cache-friendly |
## Resources
- Donchian, R. (1960). "High Finance in Copper." *Financial Analysts Journal*.
+32
View File
@@ -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*.
+32
View File
@@ -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*.
+42
View File
@@ -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.
+36
View File
@@ -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.
+33
View File
@@ -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.
+31
View File
@@ -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*.
+32
View File
@@ -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*.
+32
View File
@@ -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), 97112.
+32
View File
@@ -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), 97112.
+2
View File
@@ -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. |
+170
View File
@@ -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);
}
}
+80
View File
@@ -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<IHistoryItem, double> _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);
}
}
+592
View File
@@ -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<ArgumentException>(() => new Ccor(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Ccor_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: -5));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Ccor_ZeroThreshold_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: 20, threshold: 0.0));
Assert.Equal("threshold", ex.ParamName);
}
[Fact]
public void Ccor_NegativeThreshold_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Ccor(period: 20, threshold: -1.0));
Assert.Equal("threshold", ex.ParamName);
}
[Fact]
public void Ccor_ChainConstructor_NullSource_Throws()
{
Assert.Throws<ArgumentNullException>(() => 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<TValue>(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<double>();
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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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>();
double[] output = Array.Empty<double>();
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);
}
}
+368
View File
@@ -0,0 +1,368 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// 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.
/// </summary>
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<int>();
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
}
+435
View File
@@ -0,0 +1,435 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// From John F. Ehlers, "Correlation As A Cycle Indicator" (Stocks &amp; 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 &gt; 0: angle -= 180°)
/// 3. Monotonic constraint: angle = max(angle, prev_angle) — prevents backward spin
/// 4. State detection: |Δangle| &lt; 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)
/// </remarks>
[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;
/// <summary>Pearson correlation of price with cosine reference wave. Range [-1, +1].</summary>
public double Real { get; private set; }
/// <summary>Pearson correlation of price with negative-sine reference wave. Range [-1, +1].</summary>
public double Imag { get; private set; }
/// <summary>Phasor angle (degrees), monotonically increasing.</summary>
public double Angle { get; private set; }
/// <summary>Market state: +1 = uptrend, -1 = downtrend, 0 = cycling.</summary>
public int MarketState { get; private set; }
/// <inheritdoc />
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// Creates a new Ccor indicator.
/// </summary>
/// <param name="period">Presumed dominant cycle wavelength. Must be &gt; 0. Default 20.</param>
/// <param name="threshold">Angle rate threshold (degrees) for state detection. Must be &gt; 0. Default 9.0.</param>
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;
}
/// <summary>
/// Creates a new Ccor indicator chained to a publisher source.
/// </summary>
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;
}
/// <summary>
/// Processes a full TSeries, returning the Real correlation for each bar.
/// </summary>
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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);
}
/// <inheritdoc />
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.UtcNow, value));
}
}
/// <summary>
/// Static batch: creates a Ccor, processes source, returns output TSeries.
/// </summary>
public static TSeries Batch(TSeries source, int period = 20, double threshold = 9.0)
{
var ind = new Ccor(period, threshold);
return ind.Update(source);
}
/// <summary>
/// Static span-based batch: computes correlation cycle Real component into output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double> cosTab;
if (period <= StackallocThreshold)
{
cosTab = stackalloc double[period];
}
else
{
rentedCos = ArrayPool<double>.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<double> priceBuf;
if (period <= StackallocThreshold)
{
priceBuf = stackalloc double[period];
}
else
{
rentedBuf = ArrayPool<double>.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<double>.Shared.Return(rentedBuf);
}
}
}
finally
{
if (rentedCos != null)
{
ArrayPool<double>.Shared.Return(rentedCos);
}
}
}
/// <summary>
/// Static convenience method: returns (TSeries results, Ccor indicator) for inspection.
/// </summary>
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);
}
/// <inheritdoc />
public override void Reset()
{
_s = default;
_ps = default;
_buf.Clear();
Last = default;
Real = 0;
Imag = 0;
Angle = 0;
MarketState = 0;
}
/// <summary>
/// Computes Pearson correlation between the most recent n values in RingBuffer
/// and the first n entries of a reference wave table.
/// </summary>
[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);
}
}
+26
View File
@@ -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.
+161
View File
@@ -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);
}
}
+69
View File
@@ -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<IHistoryItem, double> _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);
}
}
+517
View File
@@ -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<ArgumentException>(() => new Ccyc(alpha: 0.0));
}
[Fact]
public void Ccyc_AlphaOne_Throws()
{
Assert.Throws<ArgumentException>(() => new Ccyc(alpha: 1.0));
}
[Fact]
public void Ccyc_AlphaNegative_Throws()
{
Assert.Throws<ArgumentException>(() => new Ccyc(alpha: -0.1));
}
[Fact]
public void Ccyc_AlphaAboveOne_Throws()
{
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<double>();
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);
}
}
}
+363
View File
@@ -0,0 +1,363 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// 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.
/// </summary>
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<double>();
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
}
+315
View File
@@ -0,0 +1,315 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 &lt; 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
/// </remarks>
[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;
/// <summary>One-bar-delayed cycle value for crossover detection.</summary>
public double Trigger { get; private set; }
/// <inheritdoc />
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// Creates a new Ccyc indicator with the specified alpha (damping factor).
/// </summary>
/// <param name="alpha">Damping factor controlling high-pass cutoff. Must be in (0, 1) exclusive. Default 0.07.</param>
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;
}
/// <summary>
/// Creates a new Ccyc indicator chained to a publisher source.
/// </summary>
/// <param name="source">Source indicator to subscribe to.</param>
/// <param name="alpha">Damping factor controlling high-pass cutoff. Default 0.07.</param>
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;
}
/// <summary>
/// Processes a full TSeries, returning the cycle component for each bar.
/// </summary>
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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);
}
/// <inheritdoc />
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.UtcNow, value));
}
}
/// <summary>
/// Static batch: creates a Ccyc, processes source, returns output TSeries.
/// </summary>
public static TSeries Batch(TSeries source, double alpha = 0.07)
{
var ind = new Ccyc(alpha);
return ind.Update(source);
}
/// <summary>
/// Static span-based batch: computes Cyber Cycle into output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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;
}
}
/// <summary>
/// Static convenience method: returns (TSeries results, Ccyc indicator) for inspection.
/// </summary>
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);
}
/// <inheritdoc />
public override void Reset()
{
_s = default;
_ps = default;
Last = default;
Trigger = 0;
}
}
+22
View File
@@ -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."
+22
View File
@@ -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.
+23
View File
@@ -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.
+28
View File
@@ -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<double>` 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.
+25
View File
@@ -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.
+28
View File
@@ -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.
+29
View File
@@ -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.
+24
View File
@@ -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<double>` 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.
+25
View File
@@ -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.
+25
View File
@@ -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.
+25
View File
@@ -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<double>` 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<double>` 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.
+25
View File
@@ -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.
+25
View File
@@ -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<double>` 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<double>` 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.
+23
View File
@@ -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.
+31
View File
@@ -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)
-1
View File
@@ -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. |
+1
View File
@@ -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. |
+222
View File
@@ -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);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
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<IHistoryItem, double>? _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));
}
}
}
+606
View File
@@ -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<ArgumentException>(() => new Sam(alpha: 0));
Assert.Equal("alpha", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativeAlpha_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Sam(alpha: -0.1));
Assert.Equal("alpha", ex.ParamName);
}
[Fact]
public void Constructor_WithAlphaGreaterThanOne_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> 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<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> 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<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> 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<double> source = [];
Span<double> 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<double>();
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
}
+410
View File
@@ -0,0 +1,410 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// 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.
/// </summary>
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
}
+428
View File
@@ -0,0 +1,428 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
/// <seealso href="sam.pine">Reference Pine Script implementation</seealso>
[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;
/// <summary>Gets the current estimated dominant cycle period.</summary>
public double DominantCycle => _s.DcPeriod;
public override bool IsHot => _s.BarCount >= WarmupPeriod;
/// <summary>
/// Creates a new Smoothed Adaptive Momentum indicator.
/// </summary>
/// <param name="alpha">Smoothing factor for cycle measurement (0 &lt; alpha &lt;= 1). Default 0.07.</param>
/// <param name="cutoff">Super Smoother cutoff period (must be >= 2). Default 8.</param>
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;
}
/// <summary>
/// Creates a chained Smoothed Adaptive Momentum indicator.
/// </summary>
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<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
/// <summary>
/// Calculates SAM for a time series.
/// </summary>
public static TSeries Batch(TSeries source, double alpha = 0.07, int cutoff = 8)
{
var sam = new Sam(alpha, cutoff);
return sam.Update(source);
}
/// <summary>
/// Calculates SAM in-place using a pre-allocated output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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);
}
}
+1
View File
@@ -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. |
@@ -10,7 +10,7 @@ namespace QuanTAlib;
/// </summary>
/// <remarks>
/// 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
+2
View File
@@ -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. |
+100
View File
@@ -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.)
@@ -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()
{
+4 -1
View File
@@ -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. |
+159
View File
@@ -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);
}
}
+56
View File
@@ -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<IHistoryItem, double> _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);
}
}
+461
View File
@@ -0,0 +1,461 @@
namespace QuanTAlib.Tests;
public class CrmaTests
{
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Crma(0));
Assert.Throws<ArgumentException>(() => new Crma(-1));
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentNullException>(() => 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);
}
}
}
@@ -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<double>();
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<double>();
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");
}
}
+431
View File
@@ -0,0 +1,431 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CRMA: Cubic Regression Moving Average
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
/// <seealso href="Crma.md">Detailed documentation</seealso>
[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;
/// <summary>
/// Creates CRMA with specified period.
/// </summary>
/// <param name="period">Lookback period (must be >= 4 for cubic regression)</param>
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;
}
/// <summary>
/// Solves the 4×4 normal equation system for cubic polynomial regression.
/// Returns the intercept a0 (fitted value at x=0, the newest bar).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double SolveCubic(ReadOnlySpan<double> 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<double> 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<double> 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<double>.Shared.Rent(count) : null;
Span<double> 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<double>.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<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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<double> 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);
}
/// <summary>
/// Calculates CRMA in-place, writing results to pre-allocated output span.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double>.Shared.Rent(len) : null;
Span<double> clean = rentedClean != null
? rentedClean.AsSpan(0, len)
: stackalloc double[len];
double[]? rentedData = period > StackAllocThreshold ? ArrayPool<double>.Shared.Rent(period) : null;
Span<double> 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<double> 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<double>.Shared.Return(rentedClean);
}
if (rentedData != null)
{
ArrayPool<double>.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);
}
/// <summary>
/// Resets the CRMA state.
/// </summary>
public override void Reset()
{
_buffer.Clear();
_state = default;
_state.LastValidValue = double.NaN;
_p_state = default;
Last = default;
}
/// <summary>
/// Disposes the Crma instance, unsubscribing from the source publisher if subscribed.
/// This method is idempotent and thread-safe.
/// </summary>
protected override void Dispose(bool disposing)
{
if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 && _source != null)
{
_source.Pub -= _handler;
_source = null;
}
base.Dispose(disposing);
}
}
+159
View File
@@ -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);
}
}
+56
View File
@@ -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<IHistoryItem, double> _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);
}
}
+461
View File
@@ -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<ArgumentException>(() => 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<TValue>(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<ArgumentException>(() => 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<ArgumentException>(() => Hend.Batch(src, output, period: 3));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_Span_EmptyInput_NoOp()
{
Hend.Batch(ReadOnlySpan<double>.Empty, Span<double>.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);
}
}
@@ -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);
}
}
+426
View File
@@ -0,0 +1,426 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HEND: Henderson Moving Average
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <seealso href="Hend.md">Detailed documentation</seealso>
[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;
/// <summary>
/// Creates HEND with specified period.
/// </summary>
/// <param name="period">Lookback period (must be odd, >= 5)</param>
[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);
}
/// <summary>
/// Creates HEND connected to a data source for event-based updates.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Hend(ITValuePublisher source, int period = 7) : this(period)
{
_source = source;
_pubHandler = Handle;
_source.Pub += _pubHandler;
}
/// <summary>
/// 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.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ComputeHendersonWeights(Span<double> 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<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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;
}
/// <summary>
/// FIR convolution using SIMD DotProduct over circular buffer.
/// Weight[0] corresponds to oldest bar, Weight[period-1] to newest.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ConvolveFull(RingBuffer buffer, double[] weights)
{
ReadOnlySpan<double> 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<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
/// <summary>
/// Calculates HEND from a TSeries using streaming updates.
/// </summary>
public static TSeries Batch(TSeries source, int period = 7)
{
var hend = new Hend(period);
return hend.Update(source);
}
/// <summary>
/// Calculates Henderson Moving Average over a span of values.
/// </summary>
/// <param name="source">Input values</param>
/// <param name="output">Output buffer (must be same length as source)</param>
/// <param name="period">Period for weight calculation (must be odd, >= 5)</param>
/// <param name="nanValue">Value to use for NaN substitution (default: NaN)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double>.Shared.Rent(usePeriod) : null;
Span<double> weights = usePeriod <= StackallocThreshold
? stackalloc double[usePeriod]
: weightsRented!.AsSpan(0, usePeriod);
// Allocate ring buffer
double[]? ringRented = usePeriod > StackallocThreshold ? ArrayPool<double>.Shared.Rent(usePeriod) : null;
Span<double> ring = usePeriod <= StackallocThreshold
? stackalloc double[usePeriod]
: ringRented!.AsSpan(0, usePeriod);
// Allocate NaN-corrected values array
double[]? cleanRented = len > StackallocThreshold ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> 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<double> 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<double>.Shared.Return(weightsRented);
}
if (ringRented != null)
{
ArrayPool<double>.Shared.Return(ringRented);
}
if (cleanRented != null)
{
ArrayPool<double>.Shared.Return(cleanRented);
}
}
}
/// <summary>
/// Creates a HEND indicator and calculates results from source.
/// </summary>
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);
}
}
+159
View File
@@ -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);
}
}
+56
View File
@@ -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<IHistoryItem, double> _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);
}
}
+400
View File
@@ -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<ArgumentException>(() => 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<ArgumentNullException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => Ilrs.Batch(src, output, period: 1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_Span_EmptyInput_NoOp()
{
Ilrs.Batch(ReadOnlySpan<double>.Empty, Span<double>.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));
}
}
@@ -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);
}
}
+416
View File
@@ -0,0 +1,416 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ILRS: Integral of Linear Regression Slope
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
/// <seealso href="Ilrs.md">Detailed documentation</seealso>
[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;
/// <summary>
/// Creates ILRS with specified period.
/// </summary>
/// <param name="period">Lookback window for slope calculation (must be &gt;= 2)</param>
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<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
/// <summary>
/// Calculates ILRS from a TSeries using streaming updates.
/// </summary>
public static TSeries Batch(TSeries source, int period = 14)
{
var ilrs = new Ilrs(period);
return ilrs.Update(source);
}
/// <summary>
/// Calculates ILRS in-place, writing results to pre-allocated output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double> 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);
}
}
@@ -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);
}
}
+59
View File
@@ -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<IHistoryItem, double> _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);
}
}

Some files were not shown because too many files have changed in this diff Show More