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 @@
-
+
[](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);