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.
This commit is contained in:
kingchenc
2026-06-02 20:50:56 +02:00
committed by GitHub
parent 2f3a0b9149
commit 93097db482
18 changed files with 500 additions and 13 deletions
+4
View File
@@ -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
+5 -5
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=289" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=290" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![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/
@@ -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),
+10
View File
@@ -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<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AnchoredVwapNode = AnchoredVWAP
export declare class AnchoredVWAP {
constructor()
+2 -1
View File
@@ -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
+42
View File
@@ -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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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")]
@@ -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",
+53
View File
@@ -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<f64> {
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<Bound<'py, PyArray1<f64>>> {
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<f64> {
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::<PyNvi>()?;
m.add_class::<PyPvi>()?;
m.add_class::<PyAdOscillator>()?;
m.add_class::<PyAnchoredRsi>()?;
m.add_class::<PyAnchoredVwap>()?;
m.add_class::<PyDemandIndex>()?;
m.add_class::<PyTsv>()?;
@@ -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
+2
View File
@@ -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),
],
)
@@ -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]
+38
View File
@@ -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<f64> {
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,
@@ -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<f64>,
sum_gain: f64,
sum_loss: f64,
last_value: Option<f64>,
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<f64> {
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<f64> {
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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
}
+4 -1
View File
@@ -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");
}
}
+2 -2
View File
@@ -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,
+3 -2
View File
@@ -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);
+1 -1
View File
@@ -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 &
+3 -1
View File
@@ -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<f64>| {
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);