From 93097db482cbe1cd406c782b8bea7676f20d7e6a Mon Sep 17 00:00:00 2001 From: kingchenc Date: Tue, 2 Jun 2026 20:50:56 +0200 Subject: [PATCH] feat: add Anchored RSI to the momentum oscillators family (#144) Cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (set_anchor), the momentum counterpart to Anchored VWAP. Scalar f64 input, 0..=100 output; wired through core, Python, Node and WASM bindings, fuzz, benches, tests and docs. Indicator count 289 -> 290. --- CHANGELOG.md | 4 + README.md | 10 +- bindings/node/__tests__/indicators.test.js | 1 + bindings/node/index.d.ts | 10 + bindings/node/index.js | 3 +- bindings/node/src/lib.rs | 42 +++ bindings/python/python/wickra/__init__.py | 2 + bindings/python/src/lib.rs | 53 ++++ bindings/python/tests/test_known_values.py | 8 + bindings/python/tests/test_lifecycle.py | 2 + bindings/python/tests/test_new_indicators.py | 36 +++ bindings/wasm/src/lib.rs | 38 +++ .../src/indicators/anchored_rsi.rs | 284 ++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 5 +- crates/wickra-core/src/lib.rs | 4 +- crates/wickra/benches/indicators.rs | 5 +- docs/README.md | 2 +- fuzz/fuzz_targets/indicator_update.rs | 4 +- 18 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 crates/wickra-core/src/indicators/anchored_rsi.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a391e4c3..5a58165d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Anchored RSI** — a cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (`set_anchor`), the momentum counterpart to Anchored VWAP. Every up- and down-move since the anchor is weighted equally, so it reports the RSI of the entire move since the anchor point. Scalar input, Momentum Oscillators family; available in Rust, Python, Node and WASM. + ## [0.4.4] - 2026-06-02 ### Added diff --git a/README.md b/README.md index e99785f1..34e60566 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 289 indicators; start at the + every one of the 290 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries ## Indicators -289 streaming-first indicators across eighteen families. Every one passes the +290 streaming-first indicators across eighteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. Each has a per-indicator deep dive (formula, parameters, warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). @@ -143,7 +143,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Family | Indicators | |--------|-----------| | Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA | -| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia | +| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia | | Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | @@ -238,7 +238,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 289 indicators +│ ├── wickra-core/ core engine + all 290 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index d141fb41..7d4218fc 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -32,6 +32,7 @@ const scalarFactories = { EMA: () => new wickra.EMA(14), WMA: () => new wickra.WMA(14), RSI: () => new wickra.RSI(14), + AnchoredRSI: () => new wickra.AnchoredRSI(), DEMA: () => new wickra.DEMA(10), TEMA: () => new wickra.TEMA(10), HMA: () => new wickra.HMA(9), diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 5788f0a8..fc6ac8e1 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -1224,6 +1224,16 @@ export declare class WilliamsAD { isReady(): boolean warmupPeriod(): number } +export type AnchoredRsiNode = AnchoredRSI +export declare class AnchoredRSI { + constructor() + setAnchor(): void + update(value: number): number | null + batch(prices: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type AnchoredVwapNode = AnchoredVWAP export declare class AnchoredVWAP { constructor() diff --git a/bindings/node/index.js b/bindings/node/index.js index 1c26bead..74107db8 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -410,6 +410,7 @@ module.exports.PVI = PVI module.exports.VolumeOscillator = VolumeOscillator module.exports.KVO = KVO module.exports.WilliamsAD = WilliamsAD +module.exports.AnchoredRSI = AnchoredRSI module.exports.AnchoredVWAP = AnchoredVWAP module.exports.DemandIndex = DemandIndex module.exports.TSV = TSV diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index ce6d72bc..d2671530 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -3179,6 +3179,48 @@ impl AdOscillatorNode { } } +// ============================== Anchored RSI ============================== + +#[napi(js_name = "AnchoredRSI")] +pub struct AnchoredRsiNode { + inner: wc::AnchoredRsi, +} + +#[napi] +impl AnchoredRsiNode { + #[napi(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + inner: wc::AnchoredRsi::new(), + } + } + #[napi(js_name = "setAnchor")] + pub fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Anchored VWAP ============================== #[napi(js_name = "AnchoredVWAP")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index a95cd868..b4c6ecd4 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -47,6 +47,7 @@ from ._wickra import ( EVWMA, # Momentum RSI, + AnchoredRSI, MACD, Stochastic, CCI, @@ -359,6 +360,7 @@ __all__ = [ "EVWMA", # Momentum "RSI", + "AnchoredRSI", "MACD", "Stochastic", "CCI", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index e97f8ee9..c5bf0dc4 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -5052,6 +5052,58 @@ impl PyAdOscillator { } } +// ============================== Anchored RSI ============================== + +#[pyclass(name = "AnchoredRSI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAnchoredRsi { + inner: wc::AnchoredRsi, +} + +#[pymethods] +impl PyAnchoredRsi { + #[new] + fn new() -> Self { + Self { + inner: wc::AnchoredRsi::new(), + } + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + /// Re-anchor the cumulative window at the next bar that arrives. + fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + /// Batch over a close-price numpy column. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "AnchoredRSI()".to_string() + } +} + // ============================== Anchored VWAP ============================== #[pyclass(name = "AnchoredVWAP", module = "wickra._wickra", skip_from_py_object)] @@ -14058,6 +14110,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 6446aace..4c1c26a5 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -66,6 +66,14 @@ def test_rsi_wilder_textbook_first_value(): assert math.isclose(out[14], 70.464, abs_tol=0.05) +def test_anchored_rsi_cumulative_reference(): + """Cumulative anchored RSI: 10 -> 11 (+1) -> 9 (-2) -> 12 (+3).""" + out = ta.AnchoredRSI().batch(np.array([10.0, 11.0, 9.0, 12.0])) + assert math.isclose(out[1], 100.0, abs_tol=1e-9) + assert math.isclose(out[2], 100.0 - 100.0 / 1.5, abs_tol=1e-6) + assert math.isclose(out[3], 100.0 - 100.0 / 3.0, abs_tol=1e-6) + + def test_inertia_constant_rvi_passes_through_linreg(): # Every bar identical (open, high, low, close) = (10, 11, 9, 10.5): # RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index e9f125e5..788e5ea7 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -12,6 +12,7 @@ SCALAR_INDICATORS = [ (ta.EMA, (14,)), (ta.WMA, (14,)), (ta.RSI, (14,)), + (ta.AnchoredRSI, ()), (ta.MACD, ()), (ta.BollingerBands, ()), ] @@ -42,6 +43,7 @@ def test_reset_returns_to_initial_state(cls, args): (ta.EMA, (14,), 14), (ta.WMA, (14,), 14), (ta.RSI, (14,), 15), + (ta.AnchoredRSI, (), 2), (ta.BollingerBands, (20, 2.0), 20), ], ) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index e662c548..c03ad29f 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1193,6 +1193,42 @@ def test_anchored_vwap_set_anchor_clears_window(): assert v == pytest.approx(100.0) +def test_anchored_rsi_reference(): + # prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3); cumulative anchored RSI. + # bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333 + # bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667 + rsi = ta.AnchoredRSI() + out = rsi.batch(np.array([10.0, 11.0, 9.0, 12.0])) + assert np.isnan(out[0]) + assert out[1] == pytest.approx(100.0) + assert out[2] == pytest.approx(33.333333, abs=1e-4) + assert out[3] == pytest.approx(66.666666, abs=1e-4) + + +def test_anchored_rsi_set_anchor_clears_window(): + # Downtrend reads 0; after re-anchor an uptrend must read a fresh 100. + rsi = ta.AnchoredRSI() + for p in (20.0, 19.0, 18.0, 17.0): + rsi.update(p) + assert rsi.is_ready() + assert rsi.value == pytest.approx(0.0) + rsi.set_anchor() + assert rsi.update(50.0) is None + assert rsi.update(51.0) == pytest.approx(100.0) + + +def test_anchored_rsi_streaming_matches_batch(): + prices = np.array([100.0 + np.sin(i * 0.4) * 8.0 for i in range(60)]) + batched = ta.AnchoredRSI().batch(prices) + streamer = ta.AnchoredRSI() + streamed = [streamer.update(float(p)) for p in prices] + for b, s in zip(batched, streamed): + if np.isnan(b): + assert s is None + else: + assert s == pytest.approx(b) + + def test_tsv_reference(): # closes = [10, 11, 13, 12, 14, 15] # volumes = [50, 100, 200, 150, 50, 200] diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index cd18638c..99b2b9c6 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1682,6 +1682,44 @@ impl WasmAdOscillator { } } +#[wasm_bindgen(js_name = AnchoredRSI)] +pub struct WasmAnchoredRsi { + inner: wc::AnchoredRsi, +} + +#[wasm_bindgen(js_class = AnchoredRSI)] +impl WasmAnchoredRsi { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmAnchoredRsi { + Self { + inner: wc::AnchoredRsi::new(), + } + } + #[wasm_bindgen(js_name = setAnchor)] + pub fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let out = flatten(self.inner.batch(prices)); + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[wasm_bindgen(js_name = AnchoredVWAP)] pub struct WasmAnchoredVwap { inner: wc::AnchoredVwap, diff --git a/crates/wickra-core/src/indicators/anchored_rsi.rs b/crates/wickra-core/src/indicators/anchored_rsi.rs new file mode 100644 index 00000000..7d263561 --- /dev/null +++ b/crates/wickra-core/src/indicators/anchored_rsi.rs @@ -0,0 +1,284 @@ +//! Anchored Relative Strength Index. + +use crate::traits::Indicator; + +/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at +/// a user-chosen anchor bar rather than over a fixed Wilder period. +/// +/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI +/// accumulates *every* up- and down-move since the anchor with equal weight, so +/// it answers "what is the RSI of the entire move since the anchor point?". The +/// running relative strength is `Σ gains / Σ losses` over all bars in the +/// current anchor window (the bar count cancels, so this equals +/// `avg_gain / avg_loss`): +/// +/// ```text +/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i) +/// ``` +/// +/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime: +/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives, +/// clearing the running sums. Because RSI needs a price *change*, the first bar +/// of a fresh anchor window only seeds the previous close and emits `None`; the +/// first value follows on the second bar (warmup period 2). +/// +/// Saturation follows the standard convention: a window with no losses yet (and +/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat +/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last +/// value unchanged. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AnchoredRsi, Indicator}; +/// +/// let mut indicator = AnchoredRsi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; +/// // Re-anchor at bar 40 (e.g. a major swing low). +/// if i == 40 { +/// indicator.set_anchor(); +/// } +/// last = indicator.update(price); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AnchoredRsi { + prev_close: Option, + sum_gain: f64, + sum_loss: f64, + last_value: Option, + pending_anchor: bool, +} + +impl AnchoredRsi { + /// Construct a fresh Anchored RSI. The first bar to arrive is the anchor. + pub const fn new() -> Self { + Self { + prev_close: None, + sum_gain: 0.0, + sum_loss: 0.0, + last_value: None, + pending_anchor: false, + } + } + + /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the + /// running sums and previous close before folding in its own bar, starting + /// a fresh anchored window. + pub fn set_anchor(&mut self) { + self.pending_anchor = true; + } + + /// Current anchored RSI value if at least one price change has been + /// observed in the current anchor window. + pub const fn value(&self) -> Option { + self.last_value + } + + fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 { + if sum_loss == 0.0 { + if sum_gain == 0.0 { + // No movement at all -> RSI undefined; standard convention returns 50. + 50.0 + } else { + 100.0 + } + } else { + let rs = sum_gain / sum_loss; + 100.0 - 100.0 / (1.0 + rs) + } + } +} + +impl Indicator for AnchoredRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + + if self.pending_anchor { + self.prev_close = None; + self.sum_gain = 0.0; + self.sum_loss = 0.0; + self.last_value = None; + self.pending_anchor = false; + } + + let Some(prev) = self.prev_close else { + self.prev_close = Some(input); + return None; + }; + self.prev_close = Some(input); + + let diff = input - prev; + if diff > 0.0 { + self.sum_gain += diff; + } else if diff < 0.0 { + self.sum_loss -= diff; + } + + let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss); + self.last_value = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.prev_close = None; + self.sum_gain = 0.0; + self.sum_loss = 0.0; + self.last_value = None; + self.pending_anchor = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "AnchoredRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = AnchoredRsi::new(); + assert_eq!(indicator.name(), "AnchoredRSI"); + assert_eq!(indicator.warmup_period(), 2); + assert_eq!(indicator.value(), None); + assert!(!indicator.is_ready()); + } + + #[test] + fn first_bar_seeds_and_returns_none() { + let mut indicator = AnchoredRsi::new(); + assert_eq!(indicator.update(100.0), None); + assert!(!indicator.is_ready()); + // Second bar produces the first value. + assert!(indicator.update(101.0).is_some()); + assert!(indicator.is_ready()); + } + + #[test] + fn pure_uptrend_saturates_at_100() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]); + assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn pure_downtrend_saturates_at_0() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]); + assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn flat_window_reads_50() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[42.0, 42.0, 42.0]); + assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn cumulative_reference_values() { + // prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3) + // after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333 + // after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667 + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]); + assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6); + assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6); + } + + #[test] + fn set_anchor_clears_old_window() { + // Downtrend, then re-anchor and pump an uptrend: the new window must + // read 100, not the blended value. + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[20.0, 19.0, 18.0, 17.0]); + assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12); + indicator.set_anchor(); + // First bar after anchor re-seeds (None), second bar emits. + assert_eq!(indicator.update(50.0), None); + let after = indicator.update(51.0).unwrap(); + assert_relative_eq!(after, 100.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_before_first_bar_acts_as_normal_start() { + let mut indicator = AnchoredRsi::new(); + indicator.set_anchor(); + assert_eq!(indicator.update(10.0), None); + assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[10.0, 11.0, 12.0]); + let before = indicator.value(); + assert!(before.is_some()); + assert_eq!(indicator.update(f64::NAN), before); + assert_eq!(indicator.update(f64::INFINITY), before); + assert_eq!(indicator.value(), before); + } + + #[test] + fn non_finite_before_any_bar_returns_none() { + let mut indicator = AnchoredRsi::new(); + assert_eq!(indicator.update(f64::NAN), None); + assert!(!indicator.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[10.0, 11.0, 12.0]); + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + assert_eq!(indicator.value(), None); + assert_eq!(indicator.update(50.0), None); + } + + #[test] + fn stays_in_0_100_range() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0) + .collect(); + let mut indicator = AnchoredRsi::new(); + for value in indicator.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i)) + .collect(); + let mut a = AnchoredRsi::new(); + let mut b = AnchoredRsi::new(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 0b5ff5d4..ff726e2f 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -16,6 +16,7 @@ mod adxr; mod alligator; mod alma; mod alpha; +mod anchored_rsi; mod anchored_vwap; mod apo; mod aroon; @@ -305,6 +306,7 @@ pub use adxr::Adxr; pub use alligator::{Alligator, AlligatorOutput}; pub use alma::Alma; pub use alpha::Alpha; +pub use anchored_rsi::AnchoredRsi; pub use anchored_vwap::AnchoredVwap; pub use apo::Apo; pub use aroon::{Aroon, AroonOutput}; @@ -617,6 +619,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "Momentum Oscillators", &[ "Rsi", + "AnchoredRsi", "Stochastic", "Cci", "Roc", @@ -981,6 +984,6 @@ mod family_tests { // the actual indicator count is the early-warning signal that an // indicator was added without being assigned a family. let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum(); - assert_eq!(total, 284, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 285, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index f21c17df..03ca9d7e 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -55,8 +55,8 @@ pub use error::{Error, Result}; pub use indicators::{ AbandonedBaby, AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle, Adl, AdvanceBlock, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, - Alpha, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, - AtrTrailingStop, Autocorrelation, AverageDrawdown, AwesomeOscillator, + Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, + AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BollingerBands, BollingerBandwidth, BollingerOutput, Breakaway, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index a9d22c07..29da35fa 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -32,8 +32,8 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci, - ClassicPivots, ConnorsRsi, DepthSlope, DerivativesTick, EffectiveSpread, Ema, + Adx, AnchoredRsi, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, + Candle, Cci, ClassicPivots, ConnorsRsi, DepthSlope, DerivativesTick, EffectiveSpread, Ema, EmpiricalModeDecomposition, Engulfing, Frama, FundingRate, FundingRateZScore, HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, KylesLambda, Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, @@ -246,6 +246,7 @@ fn benches(c: &mut Criterion) { // === Family 02 — Momentum Oscillators === // Rsi: textbook baseline; ConnorsRsi: three-component composite. bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap()); + bench_scalar(c, "anchored_rsi", &closes, AnchoredRsi::new); bench_candle_input(c, "cci", &candles, || Cci::new(20).unwrap()); bench_scalar(c, "connors_rsi", &closes, ConnorsRsi::classic); diff --git a/docs/README.md b/docs/README.md index 2bcc2388..a892fbeb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ That includes: [Python](https://docs.wickra.org/Quickstart-Python), [Node](https://docs.wickra.org/Quickstart-Node), and [WASM](https://docs.wickra.org/Quickstart-WASM). -- A per-indicator deep dive for every one of the **289 indicators** across +- A per-indicator deep dive for every one of the **290 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index e71dfada..e439df10 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,7 +15,8 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - AdaptiveCycle, Alma, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, + AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, + BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, @@ -54,6 +55,7 @@ fuzz_target!(|data: Vec| { drive(|| Ema::new(20).unwrap(), &data); drive(|| Wma::new(14).unwrap(), &data); drive(|| Rsi::new(14).unwrap(), &data); + drive(AnchoredRsi::new, &data); drive(|| Dema::new(14).unwrap(), &data); drive(|| Tema::new(14).unwrap(), &data); drive(|| Hma::new(14).unwrap(), &data);