feat: Family 02 Momentum Oscillators — RVI / PGO / KST / SMI / Laguerre / Connors / Inertia (#40)
* feat(rvi): add Relative Vigor Index
Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).
Reference: Donald Dorsey, also pandas-ta rvi.
Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.
* feat(pgo): add Pretty Good Oscillator
Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.
Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.
* feat(kst): add Know Sure Thing (Pring)
Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.
Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.
* feat(smi): add Stochastic Momentum Index (Blau)
Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.
Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).
Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.
* feat(laguerre-rsi): add Ehlers Laguerre RSI
Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.
Reference: Ehlers, Time Warp - Without Space Travel, 2002.
Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(connors-rsi): add Connors RSI (CRSI)
Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).
Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(inertia): add Dorsey Inertia (RVI + LinReg)
Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.
Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.
* test(kst): Move KST out of MULTI dict (it is scalar-input)
KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.
Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).
* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths
codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
is exactly zero so the divide-by-zero in `(input - prev) / prev` is
impossible. Exercised by seeding the first bar at 0.0.
This commit is contained in:
@@ -8,6 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a
|
||||
`LinearRegression` smoothing of the `RVI` series — preserves trend
|
||||
direction while damping the underlying ratio. Candle input, two
|
||||
parameters `(rvi_period, linreg_period)` (defaults 14 / 20). Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `ConnorsRsi`: Larry Connors'
|
||||
3-component aggregate — `RSI(close)`, `RSI(streak)`, and the
|
||||
percentile rank of the 1-bar return over the recent `period_rank`
|
||||
returns. Bounded in `[0, 100]`. Three parameters
|
||||
`(period_rsi, period_streak, period_rank)` (defaults 3 / 2 / 100).
|
||||
Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `LaguerreRsi` (Ehlers):
|
||||
four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
|
||||
accumulator. Single parameter `gamma` in `[0, 1]` (default 0.5) trades
|
||||
lag for smoothness. State is seeded to the first input so a constant
|
||||
series stays at the neutral 50. Output clamped to `[0, 100]`. Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `SMI` (Stochastic Momentum
|
||||
Index, Blau): doubly-`EMA`-smoothed bounded oscillator measuring the
|
||||
close's displacement from the centre of the recent high-low range,
|
||||
scaled by the smoothed range. Candle input, three parameters
|
||||
`(period, d_period, d2_period)` (defaults 5 / 3 / 3). Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `KST` (Know Sure Thing, Pring):
|
||||
weighted sum of four `SMA`-smoothed `ROC` series with Pring's fixed
|
||||
weights `1, 2, 3, 4`, plus an `SMA` signal line. Nine parameters
|
||||
(four ROC periods, four SMA periods, signal period); `Kst::classic()`
|
||||
uses Pring's recommended defaults. Multi-output indicator emitting
|
||||
`KstOutput { kst, signal }`. Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `PGO` (Pretty Good Oscillator,
|
||||
Mark Johnson): `(close − SMA(close, period)) / EMA(TR, period)`.
|
||||
Candle input, single parameter `period` (default 14). Roughly counts
|
||||
how many ATR-equivalents the close is from its mean. Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `RVI` (Relative Vigor Index,
|
||||
Dorsey): per-bar ratio `SMA(close - open, period) / SMA(high - low,
|
||||
period)`. Candle input, single parameter `period` (default 10).
|
||||
Positive on average-bullish windows, negative on average-bearish.
|
||||
Holds previous value if the entire window has zero range. Exposed in
|
||||
all four bindings.
|
||||
- **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average):
|
||||
Gaussian-weighted moving average with configurable centre (`offset` in
|
||||
`[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults
|
||||
|
||||
@@ -109,14 +109,14 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
78 streaming-first indicators across eight families. Every one passes the
|
||||
85 streaming-first indicators across eight families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||
|
||||
@@ -64,6 +64,8 @@ const scalarFactories = {
|
||||
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
|
||||
ZScore: () => new wickra.ZScore(20),
|
||||
LinRegAngle: () => new wickra.LinRegAngle(14),
|
||||
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
|
||||
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(scalarFactories)) {
|
||||
@@ -91,6 +93,10 @@ const candleScalar = {
|
||||
AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
EVWMA: { make: () => new wickra.EVWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
@@ -128,6 +134,7 @@ for (const [name, d] of Object.entries(candleScalar)) {
|
||||
// --- Multi-output indicators: object update vs interleaved batch ---
|
||||
|
||||
const multi = {
|
||||
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
@@ -266,6 +273,64 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => {
|
||||
assert.ok(Math.abs(out[4] - 45) < 1e-9);
|
||||
});
|
||||
|
||||
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
|
||||
const n = 60;
|
||||
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
|
||||
const out = new wickra.Inertia(3, 4).batch(
|
||||
Array(n).fill(10),
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
Array(n).fill(10.5),
|
||||
);
|
||||
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
|
||||
});
|
||||
|
||||
test('ConnorsRSI stays bounded in [0, 100]', () => {
|
||||
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
|
||||
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
|
||||
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
|
||||
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
|
||||
});
|
||||
|
||||
test('SMI with close at range centre emits zero after warmup', () => {
|
||||
const n = 60;
|
||||
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
// warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('KST on a flat series emits zero after warmup', () => {
|
||||
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
|
||||
const n = 80;
|
||||
const out = kst.batch(Array(n).fill(42));
|
||||
const warmup = kst.warmupPeriod();
|
||||
for (let i = warmup - 1; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
|
||||
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PGO(5) on a flat close emits zero after warmup', () => {
|
||||
const n = 20;
|
||||
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
|
||||
});
|
||||
|
||||
test('RVI(2) reference value on two bars', () => {
|
||||
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
|
||||
});
|
||||
|
||||
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
|
||||
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
|
||||
@@ -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, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -349,6 +349,13 @@ module.exports.RollingVWAP = RollingVWAP
|
||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||
module.exports.Aroon = Aroon
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.RVI = RVI
|
||||
module.exports.PGO = PGO
|
||||
module.exports.KST = KST
|
||||
module.exports.SMI = SMI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.Inertia = Inertia
|
||||
module.exports.ALMA = ALMA
|
||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
|
||||
@@ -1070,6 +1070,386 @@ impl AroonNode {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for OHLC-input indicators that need the open price (not provided
|
||||
// by `cnd` which fakes open == close).
|
||||
fn cnd4(open: f64, high: f64, low: f64, close: f64) -> napi::Result<wc::Candle> {
|
||||
wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
#[napi(js_name = "Inertia")]
|
||||
pub struct InertiaNode {
|
||||
inner: wc::Inertia,
|
||||
}
|
||||
#[napi]
|
||||
impl InertiaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period))
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd4(open, high, low, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low and close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], high[i], low[i], close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "ConnorsRSI")]
|
||||
pub struct ConnorsRsiNode {
|
||||
inner: wc::ConnorsRsi,
|
||||
}
|
||||
#[napi]
|
||||
impl ConnorsRsiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ConnorsRsi::new(
|
||||
clamp_period(period_rsi),
|
||||
clamp_period(period_streak),
|
||||
clamp_period(period_rank),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "LaguerreRSI")]
|
||||
pub struct LaguerreRsiNode {
|
||||
inner: wc::LaguerreRsi,
|
||||
}
|
||||
#[napi]
|
||||
impl LaguerreRsiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(gamma: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::LaguerreRsi::new(gamma).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "SMI")]
|
||||
pub struct SmiNode {
|
||||
inner: wc::Smi,
|
||||
}
|
||||
#[napi]
|
||||
impl SmiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Smi::new(
|
||||
clamp_period(period),
|
||||
clamp_period(d_period),
|
||||
clamp_period(d2_period),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if !(high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low and close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct KstValue {
|
||||
pub kst: f64,
|
||||
pub signal: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "KST")]
|
||||
pub struct KstNode {
|
||||
inner: wc::Kst,
|
||||
}
|
||||
#[napi]
|
||||
impl KstNode {
|
||||
#[napi(constructor)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
roc1: u32,
|
||||
roc2: u32,
|
||||
roc3: u32,
|
||||
roc4: u32,
|
||||
sma1: u32,
|
||||
sma2: u32,
|
||||
sma3: u32,
|
||||
sma4: u32,
|
||||
signal: u32,
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kst::new(
|
||||
clamp_period(roc1),
|
||||
clamp_period(roc2),
|
||||
clamp_period(roc3),
|
||||
clamp_period(roc4),
|
||||
clamp_period(sma1),
|
||||
clamp_period(sma2),
|
||||
clamp_period(sma3),
|
||||
clamp_period(sma4),
|
||||
clamp_period(signal),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<KstValue> {
|
||||
self.inner.update(value).map(|o| KstValue {
|
||||
kst: o.kst,
|
||||
signal: o.signal,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.kst;
|
||||
out[i * 2 + 1] = o.signal;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "PGO")]
|
||||
pub struct PgoNode {
|
||||
inner: wc::Pgo,
|
||||
}
|
||||
#[napi]
|
||||
impl PgoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Pgo::new(clamp_period(period)).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if !(high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low and close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "RVI")]
|
||||
pub struct RviNode {
|
||||
inner: wc::Rvi,
|
||||
}
|
||||
#[napi]
|
||||
impl RviNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd4(open, high, low, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low and close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], high[i], low[i], close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "KAMA")]
|
||||
pub struct KamaNode {
|
||||
inner: wc::Kama,
|
||||
|
||||
@@ -63,6 +63,13 @@ from ._wickra import (
|
||||
PMO,
|
||||
StochRSI,
|
||||
UltimateOscillator,
|
||||
RVI,
|
||||
PGO,
|
||||
KST,
|
||||
SMI,
|
||||
LaguerreRSI,
|
||||
ConnorsRSI,
|
||||
Inertia,
|
||||
PPO,
|
||||
DPO,
|
||||
Coppock,
|
||||
@@ -151,6 +158,13 @@ __all__ = [
|
||||
"PMO",
|
||||
"StochRSI",
|
||||
"UltimateOscillator",
|
||||
"RVI",
|
||||
"PGO",
|
||||
"KST",
|
||||
"SMI",
|
||||
"LaguerreRSI",
|
||||
"ConnorsRSI",
|
||||
"Inertia",
|
||||
"PPO",
|
||||
"DPO",
|
||||
"Coppock",
|
||||
|
||||
+443
-7
@@ -812,6 +812,435 @@ impl PyKama {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Inertia ==============================
|
||||
|
||||
#[pyclass(name = "Inertia", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyInertia {
|
||||
inner: wc::Inertia,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyInertia {
|
||||
#[new]
|
||||
#[pyo3(signature = (rvi_period=14, linreg_period=20))]
|
||||
fn new(rvi_period: usize, linreg_period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Inertia::new(rvi_period, linreg_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let o = open
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) {
|
||||
return Err(PyValueError::new_err(
|
||||
"open, high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
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 {
|
||||
let (r, l) = self.inner.periods();
|
||||
format!("Inertia(rvi_period={r}, linreg_period={l})")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Connors RSI ==============================
|
||||
|
||||
#[pyclass(name = "ConnorsRSI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyConnorsRsi {
|
||||
inner: wc::ConnorsRsi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConnorsRsi {
|
||||
#[new]
|
||||
#[pyo3(signature = (period_rsi=3, period_streak=2, period_rank=100))]
|
||||
fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ConnorsRsi::new(period_rsi, period_streak, period_rank).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let s = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
|
||||
}
|
||||
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 {
|
||||
let (r, s, k) = self.inner.periods();
|
||||
format!("ConnorsRSI(period_rsi={r}, period_streak={s}, period_rank={k})")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Laguerre RSI ==============================
|
||||
|
||||
#[pyclass(name = "LaguerreRSI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyLaguerreRsi {
|
||||
inner: wc::LaguerreRsi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyLaguerreRsi {
|
||||
#[new]
|
||||
#[pyo3(signature = (gamma=0.5))]
|
||||
fn new(gamma: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::LaguerreRsi::new(gamma).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let s = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn gamma(&self) -> f64 {
|
||||
self.inner.gamma()
|
||||
}
|
||||
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 {
|
||||
format!("LaguerreRSI(gamma={})", self.inner.gamma())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SMI ==============================
|
||||
|
||||
#[pyclass(name = "SMI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PySmi {
|
||||
inner: wc::Smi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySmi {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=5, d_period=3, d2_period=3))]
|
||||
fn new(period: usize, d_period: usize, d2_period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Smi::new(period, d_period, d2_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if !(h.len() == l.len() && l.len() == c.len()) {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
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 {
|
||||
let (p, d, d2) = self.inner.periods();
|
||||
format!("SMI(period={p}, d_period={d}, d2_period={d2})")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== KST ==============================
|
||||
|
||||
#[pyclass(name = "KST", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyKst {
|
||||
inner: wc::Kst,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKst {
|
||||
#[new]
|
||||
#[pyo3(signature = (roc1=10, roc2=15, roc3=20, roc4=30, sma1=10, sma2=10, sma3=10, sma4=15, signal=9))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
roc1: usize,
|
||||
roc2: usize,
|
||||
roc3: usize,
|
||||
roc4: usize,
|
||||
sma1: usize,
|
||||
sma2: usize,
|
||||
sma3: usize,
|
||||
sma4: usize,
|
||||
signal: usize,
|
||||
) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kst::new(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<(f64, f64)> {
|
||||
self.inner.update(value).map(|o| (o.kst, o.signal))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let slice = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let n = slice.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for (i, p) in slice.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.kst;
|
||||
out[i * 2 + 1] = o.signal;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
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 {
|
||||
"KST".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== PGO ==============================
|
||||
|
||||
#[pyclass(name = "PGO", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyPgo {
|
||||
inner: wc::Pgo,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPgo {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Pgo::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if !(h.len() == l.len() && l.len() == c.len()) {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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 {
|
||||
format!("PGO(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RVI ==============================
|
||||
|
||||
#[pyclass(name = "RVI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyRvi {
|
||||
inner: wc::Rvi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRvi {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=10))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rvi::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let o = open
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) {
|
||||
return Err(PyValueError::new_err(
|
||||
"open, high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(c.len());
|
||||
for i in 0..c.len() {
|
||||
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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 {
|
||||
format!("RVI(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== FRAMA ==============================
|
||||
|
||||
#[pyclass(name = "FRAMA", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -4873,13 +5302,13 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyTema>()?;
|
||||
m.add_class::<PyHma>()?;
|
||||
m.add_class::<PyKama>()?;
|
||||
m.add_class::<PyAlma>()?;
|
||||
m.add_class::<PyMcGinleyDynamic>()?;
|
||||
m.add_class::<PyFrama>()?;
|
||||
m.add_class::<PyVidya>()?;
|
||||
m.add_class::<PyJma>()?;
|
||||
m.add_class::<PyAlligator>()?;
|
||||
m.add_class::<PyEvwma>()?;
|
||||
m.add_class::<PyRvi>()?;
|
||||
m.add_class::<PyPgo>()?;
|
||||
m.add_class::<PyKst>()?;
|
||||
m.add_class::<PySmi>()?;
|
||||
m.add_class::<PyLaguerreRsi>()?;
|
||||
m.add_class::<PyConnorsRsi>()?;
|
||||
m.add_class::<PyInertia>()?;
|
||||
m.add_class::<PyCci>()?;
|
||||
m.add_class::<PyRoc>()?;
|
||||
m.add_class::<PyWilliamsR>()?;
|
||||
@@ -4939,5 +5368,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyChaikinVolatility>()?;
|
||||
m.add_class::<PyZScore>()?;
|
||||
m.add_class::<PyLinRegAngle>()?;
|
||||
m.add_class::<PyAlma>()?;
|
||||
m.add_class::<PyFrama>()?;
|
||||
m.add_class::<PyMcGinleyDynamic>()?;
|
||||
m.add_class::<PyVidya>()?;
|
||||
m.add_class::<PyJma>()?;
|
||||
m.add_class::<PyAlligator>()?;
|
||||
m.add_class::<PyEvwma>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -66,6 +66,83 @@ def test_rsi_wilder_textbook_first_value():
|
||||
assert math.isclose(out[14], 70.464, abs_tol=0.05)
|
||||
|
||||
|
||||
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
|
||||
# series equals that constant after warmup.
|
||||
n = 60
|
||||
out = ta.Inertia(3, 4).batch(
|
||||
np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5)
|
||||
)
|
||||
# warmup_period = 3 + 4 - 1 = 6.
|
||||
np.testing.assert_allclose(out[5:], 0.25, atol=1e-12)
|
||||
|
||||
|
||||
def test_connors_rsi_output_is_bounded():
|
||||
# CRSI is the average of three [0, 100] components, so the aggregate must
|
||||
# also sit in [0, 100] after warmup.
|
||||
prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250))
|
||||
out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
assert ready.min() >= 0.0
|
||||
assert ready.max() <= 100.0
|
||||
|
||||
|
||||
def test_laguerre_rsi_constant_series_stays_at_mid_band():
|
||||
# All four Laguerre stages seed to the first input, so subsequent flat
|
||||
# inputs keep them equal and the up/down accumulator is 0 — Wickra maps
|
||||
# that to the neutral 50.
|
||||
out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64))
|
||||
np.testing.assert_allclose(out, 50.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_smi_close_at_centre_yields_zero():
|
||||
# Close at the midpoint of a flat high/low range -> displacement is
|
||||
# always zero -> SMI converges to 0.
|
||||
n = 60
|
||||
out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0))
|
||||
# warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
np.testing.assert_allclose(out[8:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_kst_constant_series_yields_zero():
|
||||
# ROC is zero on a flat input, so every RCMA is zero, so KST and its
|
||||
# signal SMA are both zero after warmup.
|
||||
kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9)
|
||||
out = kst.batch(np.full(80, 42.0, dtype=np.float64))
|
||||
warmup = kst.warmup_period()
|
||||
# Use NaN-safe comparison on the post-warmup tail.
|
||||
tail = out[warmup - 1 :]
|
||||
assert np.all(np.isfinite(tail))
|
||||
np.testing.assert_allclose(tail, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_pgo_flat_close_yields_zero():
|
||||
# On a constant close the numerator (close − SMA) is zero, so PGO emits 0
|
||||
# regardless of the TR-EMA in the denominator.
|
||||
n = 20
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 10.0)
|
||||
out = ta.PGO(5).batch(high, low, close)
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_rvi_reference_value_period_2():
|
||||
# Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
# num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5.
|
||||
out = ta.RVI(2).batch(
|
||||
np.array([10.0, 10.5]),
|
||||
np.array([11.0, 11.5]),
|
||||
np.array([9.0, 10.0]),
|
||||
np.array([10.5, 11.0]),
|
||||
)
|
||||
assert math.isnan(out[0])
|
||||
assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alma_constant_series_yields_the_constant():
|
||||
# ALMA's Gaussian weights are normalised, so any constant series is
|
||||
# reproduced exactly after warmup.
|
||||
|
||||
@@ -68,6 +68,8 @@ SCALAR = [
|
||||
(ta.VerticalHorizontalFilter, (28,)),
|
||||
(ta.ZScore, (20,)),
|
||||
(ta.LinRegAngle, (14,)),
|
||||
(ta.LaguerreRSI, (0.5,)),
|
||||
(ta.ConnorsRSI, (3, 2, 100)),
|
||||
]
|
||||
|
||||
|
||||
@@ -92,6 +94,19 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
|
||||
"RVI": (
|
||||
# extract_candle pulls the open price from index 0 of the tuple; the
|
||||
# streaming test below already builds candles with open == close, so
|
||||
# match that here by passing close as the open column.
|
||||
lambda: ta.RVI(10),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Inertia": (
|
||||
lambda: ta.Inertia(14, 20),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"PGO": (lambda: ta.PGO(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"SMI": (lambda: ta.SMI(5, 3, 3), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"EVWMA": (lambda: ta.EVWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
|
||||
"UltimateOscillator": (
|
||||
lambda: ta.UltimateOscillator(7, 14, 28),
|
||||
@@ -207,6 +222,18 @@ MULTI = {
|
||||
),
|
||||
}
|
||||
|
||||
# --- Scalar-input, multi-output indicators --------------------------------
|
||||
#
|
||||
# Same shape contract as MULTI (batch returns (n, 2)) but streaming feeds a
|
||||
# single float instead of a candle tuple.
|
||||
|
||||
MULTI_SCALAR_INPUT = {
|
||||
"KST": (
|
||||
lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9),
|
||||
lambda ind, c: ind.batch(c),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(MULTI))
|
||||
def test_multi_streaming_matches_batch(name, ohlcv):
|
||||
@@ -232,6 +259,22 @@ def test_multi_streaming_matches_batch(name, ohlcv):
|
||||
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT))
|
||||
def test_multi_scalar_streaming_matches_batch(name, ohlcv):
|
||||
_, _, close, _ = ohlcv
|
||||
make, batch_call = MULTI_SCALAR_INPUT[name]
|
||||
|
||||
batch = batch_call(make(), close)
|
||||
assert batch.shape == (close.size, 2)
|
||||
|
||||
streamer = make()
|
||||
rows = []
|
||||
for p in close:
|
||||
v = streamer.update(float(p))
|
||||
rows.append([math.nan, math.nan] if v is None else list(v))
|
||||
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
|
||||
|
||||
|
||||
# --- Alligator (3-tuple output) -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period:
|
||||
wasm_scalar_indicator!(WasmVerticalHorizontalFilter, "VerticalHorizontalFilter", wc::VerticalHorizontalFilter, period: usize);
|
||||
wasm_scalar_indicator!(WasmZScore, "ZScore", wc::ZScore, period: usize);
|
||||
wasm_scalar_indicator!(WasmLinRegAngle, "LinRegAngle", wc::LinRegAngle, period: usize);
|
||||
wasm_scalar_indicator!(WasmLaguerreRsi, "LaguerreRSI", wc::LaguerreRsi, gamma: f64);
|
||||
wasm_scalar_indicator!(WasmConnorsRsi, "ConnorsRSI", wc::ConnorsRsi, period_rsi: usize, period_streak: usize, period_rank: usize);
|
||||
|
||||
// ---------- KAMA (three params) ----------
|
||||
|
||||
@@ -241,6 +243,275 @@ fn make_candle(h: f64, l: f64, c: f64, v: f64) -> Result<wc::Candle, JsError> {
|
||||
wc::Candle::new(c, h, l, c, v, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
/// Helper for OHLC-input indicators where `open` matters (`RVI`, `BalanceOfPower`).
|
||||
fn make_candle_ohlc(o: f64, h: f64, l: f64, c: f64) -> Result<wc::Candle, JsError> {
|
||||
wc::Candle::new(o, h, l, c, 0.0, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = SMI)]
|
||||
pub struct WasmSmi {
|
||||
inner: wc::Smi,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = SMI)]
|
||||
impl WasmSmi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result<WasmSmi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Smi::new(period, d_period, d2_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if !(high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(JsError::new("high, low and close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(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 = KST)]
|
||||
pub struct WasmKst {
|
||||
inner: wc::Kst,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = KST)]
|
||||
impl WasmKst {
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
roc1: usize,
|
||||
roc2: usize,
|
||||
roc3: usize,
|
||||
roc4: usize,
|
||||
sma1: usize,
|
||||
sma2: usize,
|
||||
sma3: usize,
|
||||
sma4: usize,
|
||||
signal: usize,
|
||||
) -> Result<WasmKst, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Kst::new(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[kst0, signal0, kst1, signal1, ...]`, length `2n`.
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let n = prices.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.kst;
|
||||
out[i * 2 + 1] = o.signal;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
/// Streaming update. Returns `{ kst, signal }` once warm, else `null`.
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"kst".into(), &o.kst.into()).ok();
|
||||
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
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 = PGO)]
|
||||
pub struct WasmPgo {
|
||||
inner: wc::Pgo,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = PGO)]
|
||||
impl WasmPgo {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmPgo, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Pgo::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if !(high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(JsError::new("high, low and close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(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 = Inertia)]
|
||||
pub struct WasmInertia {
|
||||
inner: wc::Inertia,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Inertia)]
|
||||
impl WasmInertia {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(rvi_period: usize, linreg_period: usize) -> Result<WasmInertia, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Inertia::new(rvi_period, linreg_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle_ohlc(open, high, low, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(JsError::new(
|
||||
"open, high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(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 = RVI)]
|
||||
pub struct WasmRvi {
|
||||
inner: wc::Rvi,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = RVI)]
|
||||
impl WasmRvi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmRvi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Rvi::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle_ohlc(open, high, low, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
|
||||
return Err(JsError::new(
|
||||
"open, high, low and close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(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 = ATR)]
|
||||
pub struct WasmAtr {
|
||||
inner: wc::Atr,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Connors RSI (CRSI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Larry Connors' RSI — average of three short-term mean-reversion components,
|
||||
/// each individually bounded in `[0, 100]` so the aggregate is too:
|
||||
///
|
||||
/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`).
|
||||
/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length
|
||||
/// (`+1, +2, ...` for consecutive up closes, `−1, −2, ...` for down closes,
|
||||
/// `0` for unchanged). Connors' default `2`.
|
||||
/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's
|
||||
/// 1-period return in the last `period_rank` returns. Connors' default `100`.
|
||||
///
|
||||
/// ```text
|
||||
/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3
|
||||
/// ```
|
||||
///
|
||||
/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors'
|
||||
/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought
|
||||
/// — both rare conditions, hence the short lookbacks.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{ConnorsRsi, Indicator};
|
||||
///
|
||||
/// let mut crsi = ConnorsRsi::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = crsi.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnorsRsi {
|
||||
period_rsi: usize,
|
||||
period_streak: usize,
|
||||
period_rank: usize,
|
||||
rsi_close: Rsi,
|
||||
rsi_streak: Rsi,
|
||||
prev_price: Option<f64>,
|
||||
streak: f64,
|
||||
/// Rolling window of the last `period_rank` 1-period returns
|
||||
/// (`(price_t − price_{t-1}) / price_{t-1}`).
|
||||
rocs: VecDeque<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl ConnorsRsi {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any of the three periods is zero.
|
||||
pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result<Self> {
|
||||
if period_rsi == 0 || period_streak == 0 || period_rank == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period_rsi,
|
||||
period_streak,
|
||||
period_rank,
|
||||
rsi_close: Rsi::new(period_rsi)?,
|
||||
rsi_streak: Rsi::new(period_streak)?,
|
||||
prev_price: None,
|
||||
streak: 0.0,
|
||||
rocs: VecDeque::with_capacity(period_rank),
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period_rsi, period_streak, period_rank)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.period_rsi, self.period_streak, self.period_rank)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ConnorsRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
// Run the close-RSI on every input so it warms up regardless of the
|
||||
// streak / percent-rank branches.
|
||||
let rsi_close = self.rsi_close.update(input);
|
||||
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
|
||||
// Update the up/down streak run length.
|
||||
self.streak = if input > prev {
|
||||
self.streak.max(0.0) + 1.0
|
||||
} else if input < prev {
|
||||
self.streak.min(0.0) - 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rsi_streak = self.rsi_streak.update(self.streak);
|
||||
|
||||
// 1-period return; defined only when the previous price is non-zero.
|
||||
if prev != 0.0 {
|
||||
let roc = (input - prev) / prev;
|
||||
if self.rocs.len() == self.period_rank {
|
||||
self.rocs.pop_front();
|
||||
}
|
||||
self.rocs.push_back(roc);
|
||||
}
|
||||
self.prev_price = Some(input);
|
||||
|
||||
// PercentRank emits once the ROC window has filled.
|
||||
let percent_rank = if self.rocs.len() == self.period_rank {
|
||||
let latest = *self.rocs.back().expect("non-empty window");
|
||||
let below = self.rocs.iter().filter(|&&r| r < latest).count();
|
||||
Some(100.0 * below as f64 / self.period_rank as f64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let value = (rsi_close?, rsi_streak?, percent_rank?);
|
||||
let crsi = (value.0 + value.1 + value.2) / 3.0;
|
||||
self.current = Some(crsi);
|
||||
Some(crsi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi_close.reset();
|
||||
self.rsi_streak.reset();
|
||||
self.prev_price = None;
|
||||
self.streak = 0.0;
|
||||
self.rocs.clear();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The slowest branch is the percent-rank: it needs period_rank + 1
|
||||
// prices (period_rank one-period returns). The close-RSI needs
|
||||
// period_rsi + 1 prices and the streak-RSI needs period_streak + 1
|
||||
// streak values = period_streak + 2 prices. The rank branch dominates
|
||||
// for Connors' defaults.
|
||||
let rsi_close = self.period_rsi + 1;
|
||||
let rsi_streak = self.period_streak + 2;
|
||||
let rank = self.period_rank + 1;
|
||||
rsi_close.max(rsi_streak).max(rank)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ConnorsRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let crsi = ConnorsRsi::classic();
|
||||
assert_eq!(crsi.periods(), (3, 2, 100));
|
||||
assert_eq!(crsi.name(), "ConnorsRSI");
|
||||
// Slowest branch: percent_rank with period_rank + 1 = 101.
|
||||
assert_eq!(crsi.warmup_period(), 101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
// Use small periods so the test is fast.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap();
|
||||
// Slowest: 5 + 1 = 6.
|
||||
assert_eq!(crsi.warmup_period(), 6);
|
||||
let prices: Vec<f64> = (1..=8).map(f64::from).collect();
|
||||
let out = crsi.batch(&prices);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_high() {
|
||||
// A monotonic uptrend drives all three components toward 100:
|
||||
// RSI of monotonic ups is 100, streak stays positive and growing so
|
||||
// its RSI is 100, and every new 1-period return matches the prior
|
||||
// ones so percent rank stabilises near 0 — but the average of all
|
||||
// three still climbs well above 50.
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
for i in 1..=200 {
|
||||
crsi.update(f64::from(i));
|
||||
}
|
||||
let v = crsi.current.unwrap();
|
||||
assert!(
|
||||
v > 60.0,
|
||||
"uptrend should drive Connors RSI well above 50: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_bounded() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (0..300)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0)
|
||||
.collect();
|
||||
for v in crsi.batch(&prices).iter().flatten() {
|
||||
assert!(
|
||||
(0.0..=100.0).contains(v),
|
||||
"Connors RSI out of [0, 100]: {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_resets_to_zero_on_unchanged_close() {
|
||||
// Helper: feed a sequence and inspect the internal streak.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap();
|
||||
crsi.update(10.0);
|
||||
crsi.update(11.0);
|
||||
crsi.update(12.0);
|
||||
assert_eq!(crsi.streak, 2.0);
|
||||
crsi.update(12.0);
|
||||
assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12);
|
||||
crsi.update(11.0);
|
||||
assert_eq!(crsi.streak, -1.0);
|
||||
crsi.update(10.0);
|
||||
assert_eq!(crsi.streak, -2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut a = ConnorsRsi::classic();
|
||||
let mut b = ConnorsRsi::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
crsi.batch(&prices);
|
||||
assert!(crsi.is_ready());
|
||||
crsi.reset();
|
||||
assert!(!crsi.is_ready());
|
||||
assert_eq!(crsi.streak, 0.0);
|
||||
assert!(crsi.prev_price.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
crsi.batch(&prices);
|
||||
let before = crsi.current;
|
||||
assert_eq!(crsi.update(f64::NAN), before);
|
||||
assert_eq!(crsi.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_skips_roc_update() {
|
||||
// A previous price of 0.0 makes the 1-bar return undefined; the
|
||||
// ROC ring buffer must be left unchanged on that step. Feeding
|
||||
// 0.0 as the very first price seeds `prev_price = Some(0.0)`, so
|
||||
// the next bar takes the `prev == 0.0` branch.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap();
|
||||
// Bar 1 seeds prev_price to 0.0.
|
||||
crsi.update(0.0);
|
||||
// Bar 2 must not push onto the ROC window; we cannot observe the
|
||||
// ring directly but the indicator must not panic and must not
|
||||
// emit until at least period_rank distinct non-zero returns have
|
||||
// accumulated.
|
||||
let after = crsi.update(1.0);
|
||||
assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Inertia (Donald Dorsey).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::linreg::LinearRegression;
|
||||
use crate::indicators::rvi::Rvi;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor
|
||||
/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is
|
||||
/// taken as the indicator's reading, smoothing the underlying ratio while
|
||||
/// preserving its trend direction.
|
||||
///
|
||||
/// ```text
|
||||
/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t
|
||||
/// ```
|
||||
///
|
||||
/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Inertia};
|
||||
///
|
||||
/// let mut inertia = Inertia::new(14, 20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let o = 100.0 + f64::from(i);
|
||||
/// let c = o + 0.5;
|
||||
/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
|
||||
/// last = inertia.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Inertia {
|
||||
rvi_period: usize,
|
||||
linreg_period: usize,
|
||||
rvi: Rvi,
|
||||
linreg: LinearRegression,
|
||||
}
|
||||
|
||||
impl Inertia {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either period is zero.
|
||||
pub fn new(rvi_period: usize, linreg_period: usize) -> Result<Self> {
|
||||
if rvi_period == 0 || linreg_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rvi_period,
|
||||
linreg_period,
|
||||
rvi: Rvi::new(rvi_period)?,
|
||||
linreg: LinearRegression::new(linreg_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 20).expect("classic Inertia parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(rvi_period, linreg_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.rvi_period, self.linreg_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Inertia {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let rvi = self.rvi.update(candle)?;
|
||||
self.linreg.update(rvi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rvi.reset();
|
||||
self.linreg.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RVI emits at `rvi_period` candles; the LinearRegression then needs
|
||||
// `linreg_period − 1` more RVI values to fill its window.
|
||||
self.rvi_period + self.linreg_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.linreg.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Inertia"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let inertia = Inertia::classic();
|
||||
assert_eq!(inertia.periods(), (14, 20));
|
||||
assert_eq!(inertia.warmup_period(), 33);
|
||||
assert_eq!(inertia.name(), "Inertia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(Inertia::classic().periods(), (14, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
// Smaller periods for a fast test: RVI(3) emits at 3 candles, then
|
||||
// LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6.
|
||||
let mut inertia = Inertia::new(3, 4).unwrap();
|
||||
assert_eq!(inertia.warmup_period(), 6);
|
||||
for i in 0..5 {
|
||||
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
|
||||
}
|
||||
assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_rvi_yields_constant_inertia() {
|
||||
// Every bar identical -> RVI is constant -> LinReg of a constant
|
||||
// series equals that constant after warmup.
|
||||
let mut inertia = Inertia::new(3, 4).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..40 {
|
||||
last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
// RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar.
|
||||
let v = last.unwrap();
|
||||
assert_relative_eq!(v, 0.25, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||
let c = o + (i as f64 * 0.1).cos();
|
||||
candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Inertia::classic().batch(&candles);
|
||||
let mut b = Inertia::classic();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut inertia = Inertia::classic();
|
||||
for i in 0..50 {
|
||||
inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
assert!(inertia.is_ready());
|
||||
inertia.reset();
|
||||
assert!(!inertia.is_ready());
|
||||
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Know Sure Thing (KST).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roc::Roc;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `KST` output: the indicator line and its `SMA` signal line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KstOutput {
|
||||
/// Weighted sum of four smoothed `ROC` series.
|
||||
pub kst: f64,
|
||||
/// `SMA` of `kst` over the signal period.
|
||||
pub signal: f64,
|
||||
}
|
||||
|
||||
/// Pring's Know Sure Thing — a long-horizon momentum oscillator that combines
|
||||
/// four `ROC` series at different lookbacks, each smoothed by its own `SMA`,
|
||||
/// summed with Pring's fixed weights `1, 2, 3, 4`:
|
||||
///
|
||||
/// ```text
|
||||
/// RCMA_i = SMA(ROC(close, roc_i), sma_i) for i = 1..=4
|
||||
/// KST = 1·RCMA_1 + 2·RCMA_2 + 3·RCMA_3 + 4·RCMA_4
|
||||
/// Signal = SMA(KST, signal_period)
|
||||
/// ```
|
||||
///
|
||||
/// Pring's recommended defaults are
|
||||
/// `(roc1, roc2, roc3, roc4) = (10, 15, 20, 30)`,
|
||||
/// `(sma1, sma2, sma3, sma4) = (10, 10, 10, 15)`,
|
||||
/// `signal_period = 9`. `Kst::classic()` constructs that configuration.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Kst};
|
||||
///
|
||||
/// let mut kst = Kst::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = kst.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Kst {
|
||||
roc1_period: usize,
|
||||
roc2_period: usize,
|
||||
roc3_period: usize,
|
||||
roc4_period: usize,
|
||||
sma1_period: usize,
|
||||
sma2_period: usize,
|
||||
sma3_period: usize,
|
||||
sma4_period: usize,
|
||||
signal_period: usize,
|
||||
roc1: Roc,
|
||||
roc2: Roc,
|
||||
roc3: Roc,
|
||||
roc4: Roc,
|
||||
sma1: Sma,
|
||||
sma2: Sma,
|
||||
sma3: Sma,
|
||||
sma4: Sma,
|
||||
signal_sma: Sma,
|
||||
last_line: Option<f64>,
|
||||
last_signal: Option<f64>,
|
||||
}
|
||||
|
||||
impl Kst {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any of the nine periods is zero.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
roc1: usize,
|
||||
roc2: usize,
|
||||
roc3: usize,
|
||||
roc4: usize,
|
||||
sma1: usize,
|
||||
sma2: usize,
|
||||
sma3: usize,
|
||||
sma4: usize,
|
||||
signal: usize,
|
||||
) -> Result<Self> {
|
||||
if [roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal].contains(&0) {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
roc1_period: roc1,
|
||||
roc2_period: roc2,
|
||||
roc3_period: roc3,
|
||||
roc4_period: roc4,
|
||||
sma1_period: sma1,
|
||||
sma2_period: sma2,
|
||||
sma3_period: sma3,
|
||||
sma4_period: sma4,
|
||||
signal_period: signal,
|
||||
roc1: Roc::new(roc1)?,
|
||||
roc2: Roc::new(roc2)?,
|
||||
roc3: Roc::new(roc3)?,
|
||||
roc4: Roc::new(roc4)?,
|
||||
sma1: Sma::new(sma1)?,
|
||||
sma2: Sma::new(sma2)?,
|
||||
sma3: Sma::new(sma3)?,
|
||||
sma4: Sma::new(sma4)?,
|
||||
signal_sma: Sma::new(signal)?,
|
||||
last_line: None,
|
||||
last_signal: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pring's recommended defaults: `KST(10, 15, 20, 30, 10, 10, 10, 15, 9)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 15, 20, 30, 10, 10, 10, 15, 9).expect("classic KST parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)`.
|
||||
pub const fn periods(
|
||||
&self,
|
||||
) -> (
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
usize,
|
||||
) {
|
||||
(
|
||||
self.roc1_period,
|
||||
self.roc2_period,
|
||||
self.roc3_period,
|
||||
self.roc4_period,
|
||||
self.sma1_period,
|
||||
self.sma2_period,
|
||||
self.sma3_period,
|
||||
self.sma4_period,
|
||||
self.signal_period,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Kst {
|
||||
type Input = f64;
|
||||
type Output = KstOutput;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<KstOutput> {
|
||||
// Feed every inner state machine on every input so they warm up in
|
||||
// parallel. The KST line waits for all four RCMA branches; the signal
|
||||
// line additionally waits for its own SMA to fill.
|
||||
let r1 = self.roc1.update(input);
|
||||
let r2 = self.roc2.update(input);
|
||||
let r3 = self.roc3.update(input);
|
||||
let r4 = self.roc4.update(input);
|
||||
let rcma1 = r1.and_then(|x| self.sma1.update(x));
|
||||
let rcma2 = r2.and_then(|x| self.sma2.update(x));
|
||||
let rcma3 = r3.and_then(|x| self.sma3.update(x));
|
||||
let rcma4 = r4.and_then(|x| self.sma4.update(x));
|
||||
let (rcma1, rcma2, rcma3, rcma4) = (rcma1?, rcma2?, rcma3?, rcma4?);
|
||||
let kst = rcma1 + 2.0 * rcma2 + 3.0 * rcma3 + 4.0 * rcma4;
|
||||
self.last_line = Some(kst);
|
||||
let signal = self.signal_sma.update(kst);
|
||||
let signal = signal?;
|
||||
self.last_signal = Some(signal);
|
||||
Some(KstOutput { kst, signal })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roc1.reset();
|
||||
self.roc2.reset();
|
||||
self.roc3.reset();
|
||||
self.roc4.reset();
|
||||
self.sma1.reset();
|
||||
self.sma2.reset();
|
||||
self.sma3.reset();
|
||||
self.sma4.reset();
|
||||
self.signal_sma.reset();
|
||||
self.last_line = None;
|
||||
self.last_signal = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Each RCMA_i emits once the inner ROC has warmed up (roc_i + 1
|
||||
// inputs) AND the SMA has filled (sma_i inputs through it). All four
|
||||
// run in parallel so the slowest branch dominates, and the signal SMA
|
||||
// adds signal_period − 1 inputs on top of the slowest branch.
|
||||
let branch = |roc: usize, sma: usize| roc + sma;
|
||||
let slowest = branch(self.roc1_period, self.sma1_period)
|
||||
.max(branch(self.roc2_period, self.sma2_period))
|
||||
.max(branch(self.roc3_period, self.sma3_period))
|
||||
.max(branch(self.roc4_period, self.sma4_period));
|
||||
slowest + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_signal.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KST"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
Kst::new(0, 15, 20, 30, 10, 10, 10, 15, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
Kst::new(10, 15, 20, 30, 10, 10, 10, 15, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let kst = Kst::classic();
|
||||
assert_eq!(kst.periods(), (10, 15, 20, 30, 10, 10, 10, 15, 9));
|
||||
assert_eq!(kst.name(), "KST");
|
||||
// The slowest branch is ROC(30) + SMA(15) = 45; signal_period - 1 = 8.
|
||||
assert_eq!(kst.warmup_period(), 53);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory_matches_pring_defaults() {
|
||||
let kst = Kst::classic();
|
||||
let (r1, r2, r3, r4, s1, s2, s3, s4, sig) = kst.periods();
|
||||
assert_eq!((r1, r2, r3, r4), (10, 15, 20, 30));
|
||||
assert_eq!((s1, s2, s3, s4), (10, 10, 10, 15));
|
||||
assert_eq!(sig, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// ROC is zero on a flat series, so every RCMA collapses to zero and
|
||||
// KST itself is zero. The signal SMA inherits that.
|
||||
let mut kst = Kst::classic();
|
||||
let prices = vec![42.0_f64; 80];
|
||||
let out = kst.batch(&prices);
|
||||
for v in out.iter().skip(kst.warmup_period() - 1).flatten() {
|
||||
assert_relative_eq!(v.kst, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut kst = Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap();
|
||||
// Slowest branch is ROC(5) + SMA(3) = 8; signal − 1 = 1; total 9.
|
||||
assert_eq!(kst.warmup_period(), 9);
|
||||
let prices: Vec<f64> = (1..=15).map(f64::from).collect();
|
||||
let out = kst.batch(&prices);
|
||||
for v in out.iter().take(8) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[8].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
// Monotonic uptrend -> every ROC > 0 -> every RCMA > 0 -> KST > 0.
|
||||
let mut kst = Kst::classic();
|
||||
let prices: Vec<f64> = (1..=120).map(|i| f64::from(i) * 2.0).collect();
|
||||
let out = kst.batch(&prices);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(
|
||||
last.kst > 0.0,
|
||||
"KST on a clean uptrend should be positive: {}",
|
||||
last.kst
|
||||
);
|
||||
assert!(last.signal > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut a = Kst::classic();
|
||||
let mut b = Kst::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut kst = Kst::classic();
|
||||
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
|
||||
kst.batch(&prices);
|
||||
assert!(kst.is_ready());
|
||||
kst.reset();
|
||||
assert!(!kst.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Ehlers' Laguerre RSI.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// John Ehlers' Laguerre RSI — a four-stage Laguerre polynomial filter wrapped
|
||||
/// in an `RSI`-style up/down accumulator. The single tuning parameter `gamma`
|
||||
/// in `[0, 1]` trades lag for smoothness: small `gamma` is fast and noisy,
|
||||
/// large `gamma` is slow and smooth (Ehlers recommends `0.5`).
|
||||
///
|
||||
/// ```text
|
||||
/// alpha = 1 − gamma
|
||||
/// L0_t = alpha · price_t + gamma · L0_{t-1}
|
||||
/// L1_t = −gamma · L0_t + L0_{t-1} + gamma · L1_{t-1}
|
||||
/// L2_t = −gamma · L1_t + L1_{t-1} + gamma · L2_{t-1}
|
||||
/// L3_t = −gamma · L2_t + L2_{t-1} + gamma · L3_{t-1}
|
||||
///
|
||||
/// cu, cd = 0
|
||||
/// for each pair (L0, L1), (L1, L2), (L2, L3):
|
||||
/// if upper ≥ lower: cu += upper − lower
|
||||
/// else : cd += lower − upper
|
||||
///
|
||||
/// LRSI = 100 · cu / (cu + cd)
|
||||
/// ```
|
||||
///
|
||||
/// The output is bounded in `[0, 100]`. State is seeded by setting all four
|
||||
/// `L_i` to the first input, so the first emission lands on input #1.
|
||||
///
|
||||
/// Reference: John F. Ehlers, *Time Warp — Without Space Travel*, 2002.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, LaguerreRsi};
|
||||
///
|
||||
/// let mut lrsi = LaguerreRsi::new(0.5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = lrsi.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaguerreRsi {
|
||||
gamma: f64,
|
||||
alpha: f64,
|
||||
l0: f64,
|
||||
l1: f64,
|
||||
l2: f64,
|
||||
l3: f64,
|
||||
seeded: bool,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl LaguerreRsi {
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `gamma` is non-finite or outside `[0, 1]`.
|
||||
pub fn new(gamma: f64) -> Result<Self> {
|
||||
if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "LaguerreRSI gamma must be a finite value in [0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
gamma,
|
||||
alpha: 1.0 - gamma,
|
||||
l0: 0.0,
|
||||
l1: 0.0,
|
||||
l2: 0.0,
|
||||
l3: 0.0,
|
||||
seeded: false,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ehlers' recommended `gamma = 0.5`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(0.5).expect("classic LaguerreRSI gamma is valid")
|
||||
}
|
||||
|
||||
/// Configured `gamma`.
|
||||
pub const fn gamma(&self) -> f64 {
|
||||
self.gamma
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for LaguerreRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
if !self.seeded {
|
||||
// Seed all four polynomial stages with the first input so a
|
||||
// constant series produces zero up/down accumulators (which we
|
||||
// map to 50.0 below — the canonical neutral mid-band reading).
|
||||
self.l0 = input;
|
||||
self.l1 = input;
|
||||
self.l2 = input;
|
||||
self.l3 = input;
|
||||
self.seeded = true;
|
||||
self.current = Some(50.0);
|
||||
return self.current;
|
||||
}
|
||||
let (l0_prev, l1_prev, l2_prev) = (self.l0, self.l1, self.l2);
|
||||
let l0_new = self.alpha * input + self.gamma * l0_prev;
|
||||
let l1_new = -self.gamma * l0_new + l0_prev + self.gamma * self.l1;
|
||||
let l2_new = -self.gamma * l1_new + l1_prev + self.gamma * self.l2;
|
||||
let l3_new = -self.gamma * l2_new + l2_prev + self.gamma * self.l3;
|
||||
self.l0 = l0_new;
|
||||
self.l1 = l1_new;
|
||||
self.l2 = l2_new;
|
||||
self.l3 = l3_new;
|
||||
|
||||
let mut cu = 0.0;
|
||||
let mut cd = 0.0;
|
||||
let pairs = [(l0_new, l1_new), (l1_new, l2_new), (l2_new, l3_new)];
|
||||
for (upper, lower) in pairs {
|
||||
if upper >= lower {
|
||||
cu += upper - lower;
|
||||
} else {
|
||||
cd += lower - upper;
|
||||
}
|
||||
}
|
||||
let total = cu + cd;
|
||||
let value = if total > 0.0 {
|
||||
// Floating-point rounding can push `cu / total` a hair above 1.0;
|
||||
// clamp to the algebraic bound to keep the output strictly inside
|
||||
// [0, 100].
|
||||
(100.0 * cu / total).clamp(0.0, 100.0)
|
||||
} else {
|
||||
// No up- or down-displacements between stages: stay at the
|
||||
// neutral mid-band rather than report 0 / 0.
|
||||
50.0
|
||||
};
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.l0 = 0.0;
|
||||
self.l1 = 0.0;
|
||||
self.l2 = 0.0;
|
||||
self.l3 = 0.0;
|
||||
self.seeded = false;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"LaguerreRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_gamma() {
|
||||
assert!(matches!(
|
||||
LaguerreRsi::new(-0.1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
LaguerreRsi::new(1.1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
LaguerreRsi::new(f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let lrsi = LaguerreRsi::new(0.5).unwrap();
|
||||
assert_eq!(lrsi.gamma(), 0.5);
|
||||
assert_eq!(lrsi.warmup_period(), 1);
|
||||
assert_eq!(lrsi.name(), "LaguerreRSI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(LaguerreRsi::classic().gamma(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_stays_at_mid_band() {
|
||||
// All four L_i seed to the constant; on subsequent flat inputs they
|
||||
// stay equal, so cu = cd = 0 and LRSI reports the neutral 50.
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
let out = lrsi.batch(&[42.0_f64; 60]);
|
||||
for v in out.iter().flatten() {
|
||||
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_bounded() {
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 25.0)
|
||||
.collect();
|
||||
for v in lrsi.batch(&prices).iter().flatten() {
|
||||
assert!(*v >= 0.0 && *v <= 100.0, "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_high() {
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
for i in 0..200 {
|
||||
lrsi.update(100.0 + f64::from(i));
|
||||
}
|
||||
let v = lrsi.current.unwrap();
|
||||
assert!(v > 80.0, "uptrend should drive LRSI well above 50: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_saturates_low() {
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
for i in 0..200 {
|
||||
lrsi.update(300.0 - f64::from(i));
|
||||
}
|
||||
let v = lrsi.current.unwrap();
|
||||
assert!(v < 20.0, "downtrend should drive LRSI well below 50: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = LaguerreRsi::classic();
|
||||
let mut b = LaguerreRsi::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
lrsi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(lrsi.is_ready());
|
||||
lrsi.reset();
|
||||
assert!(!lrsi.is_ready());
|
||||
assert!(!lrsi.seeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut lrsi = LaguerreRsi::classic();
|
||||
let before = lrsi.update(10.0).unwrap();
|
||||
assert_eq!(lrsi.update(f64::NAN), Some(before));
|
||||
assert_eq!(lrsi.update(f64::INFINITY), Some(before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamma_zero_passes_through_l0() {
|
||||
// gamma = 0 -> alpha = 1, so L0 mirrors the input exactly each step.
|
||||
// The polynomial chain then lags by one stage; the up/down accumulator
|
||||
// still produces a bounded reading and the first non-seed step shifts
|
||||
// off 50 as soon as input changes.
|
||||
let mut lrsi = LaguerreRsi::new(0.0).unwrap();
|
||||
assert_eq!(lrsi.update(10.0), Some(50.0));
|
||||
let v = lrsi.update(11.0).unwrap();
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mod chandelier_exit;
|
||||
mod choppiness_index;
|
||||
mod cmf;
|
||||
mod cmo;
|
||||
mod connors_rsi;
|
||||
mod coppock;
|
||||
mod dema;
|
||||
mod donchian;
|
||||
@@ -36,9 +37,12 @@ mod force_index;
|
||||
mod frama;
|
||||
mod historical_volatility;
|
||||
mod hma;
|
||||
mod inertia;
|
||||
mod jma;
|
||||
mod kama;
|
||||
mod keltner;
|
||||
mod kst;
|
||||
mod laguerre_rsi;
|
||||
mod linreg;
|
||||
mod linreg_angle;
|
||||
mod linreg_slope;
|
||||
@@ -51,12 +55,15 @@ mod mom;
|
||||
mod natr;
|
||||
mod obv;
|
||||
mod percent_b;
|
||||
mod pgo;
|
||||
mod pmo;
|
||||
mod ppo;
|
||||
mod psar;
|
||||
mod roc;
|
||||
mod rsi;
|
||||
mod rvi;
|
||||
mod sma;
|
||||
mod smi;
|
||||
mod smma;
|
||||
mod std_dev;
|
||||
mod stoch_rsi;
|
||||
@@ -104,6 +111,7 @@ pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
|
||||
pub use choppiness_index::ChoppinessIndex;
|
||||
pub use cmf::ChaikinMoneyFlow;
|
||||
pub use cmo::Cmo;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use dema::Dema;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
@@ -115,9 +123,12 @@ pub use force_index::ForceIndex;
|
||||
pub use frama::Frama;
|
||||
pub use historical_volatility::HistoricalVolatility;
|
||||
pub use hma::Hma;
|
||||
pub use inertia::Inertia;
|
||||
pub use jma::Jma;
|
||||
pub use kama::Kama;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kst::{Kst, KstOutput};
|
||||
pub use laguerre_rsi::LaguerreRsi;
|
||||
pub use linreg::LinearRegression;
|
||||
pub use linreg_angle::LinRegAngle;
|
||||
pub use linreg_slope::LinRegSlope;
|
||||
@@ -130,12 +141,15 @@ pub use mom::Mom;
|
||||
pub use natr::Natr;
|
||||
pub use obv::Obv;
|
||||
pub use percent_b::PercentB;
|
||||
pub use pgo::Pgo;
|
||||
pub use pmo::Pmo;
|
||||
pub use ppo::Ppo;
|
||||
pub use psar::Psar;
|
||||
pub use roc::Roc;
|
||||
pub use rsi::Rsi;
|
||||
pub use rvi::Rvi;
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
pub use smma::Smma;
|
||||
pub use std_dev::StdDev;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Pretty Good Oscillator (PGO).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::indicators::true_range::TrueRange;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Mark Johnson's Pretty Good Oscillator — displacement of the close from its
|
||||
/// `period`-bar `SMA`, normalised by the `period`-bar `EMA` of the True Range.
|
||||
///
|
||||
/// ```text
|
||||
/// PGO_t = (close_t − SMA(close, period)_t) / EMA(TR_t, period)
|
||||
/// ```
|
||||
///
|
||||
/// The numerator is positive when the close is above its mean of the last
|
||||
/// `period` bars and negative when below. The denominator is the EMA-smoothed
|
||||
/// volatility scale, so PGO is roughly "how many ATR-equivalents is the close
|
||||
/// away from its mean?". Johnson's heuristic: cross above `+3` is a long entry,
|
||||
/// below `−3` a short entry.
|
||||
///
|
||||
/// The first output lands once both inner indicators have warmed up — for the
|
||||
/// shared `period` parameter, that is exactly `period` candles in.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Pgo};
|
||||
///
|
||||
/// let mut pgo = Pgo::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap();
|
||||
/// last = pgo.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pgo {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
tr: TrueRange,
|
||||
ema_tr: Ema,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Pgo {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
tr: TrueRange::new(),
|
||||
ema_tr: Ema::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Pgo {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let mean = self.sma.update(candle.close);
|
||||
// TrueRange always emits (it falls back to high − low without a
|
||||
// previous close), so we can unwrap the inner option safely.
|
||||
let tr = self.tr.update(candle).expect("TrueRange always emits");
|
||||
let ema_tr = self.ema_tr.update(tr);
|
||||
let mean = mean?;
|
||||
let ema_tr = ema_tr?;
|
||||
if ema_tr <= 0.0 {
|
||||
// Pathological window of perfectly flat candles: divisor zero.
|
||||
// Hold the previous value rather than blow up.
|
||||
return self.current;
|
||||
}
|
||||
let value = (candle.close - mean) / ema_tr;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
self.tr.reset();
|
||||
self.ema_tr.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Both inner state machines reach readiness at exactly `period`
|
||||
// candles, so PGO emits at the same boundary.
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PGO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Pgo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut p = Pgo::new(14).unwrap();
|
||||
assert_eq!(p.period(), 14);
|
||||
assert_eq!(p.warmup_period(), 14);
|
||||
assert_eq!(p.name(), "PGO");
|
||||
assert!(!p.is_ready());
|
||||
for i in 0..14 {
|
||||
p.update(candle(10.0, 11.0, 9.0, i));
|
||||
}
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_close_yields_zero_numerator() {
|
||||
// Constant close -> SMA == close, so numerator is 0 regardless of the
|
||||
// TR-EMA in the denominator (which is non-zero thanks to spread).
|
||||
let mut p = Pgo::new(5).unwrap();
|
||||
let mut out = None;
|
||||
for i in 0..20 {
|
||||
out = p.update(candle(10.0, 11.0, 9.0, i));
|
||||
}
|
||||
let v = out.unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut p = Pgo::new(3).unwrap();
|
||||
for i in 0..2 {
|
||||
assert_eq!(p.update(candle(10.0, 11.0, 9.0, i)), None);
|
||||
}
|
||||
assert!(p.update(candle(10.0, 11.0, 9.0, 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_above_mean_is_positive() {
|
||||
// Rising series: latest close sits above its SMA, so PGO > 0.
|
||||
let mut p = Pgo::new(5).unwrap();
|
||||
for i in 0..20 {
|
||||
let c = 10.0 + f64::from(i);
|
||||
p.update(candle(c, c + 0.5, c - 0.5, i64::from(i)));
|
||||
}
|
||||
// Use the last value implicitly.
|
||||
let last = p.update(candle(40.0, 40.5, 39.5, 20)).expect("PGO is warm");
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"PGO on rising series should be positive: {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_tr_holds_value() {
|
||||
// Every candle is a single point (high == low == close): TR is zero,
|
||||
// EMA(TR) collapses to zero -> PGO holds its previous value.
|
||||
let mut p = Pgo::new(3).unwrap();
|
||||
p.update(candle(10.0, 10.0, 10.0, 0));
|
||||
p.update(candle(10.0, 10.0, 10.0, 1));
|
||||
let v = p.update(candle(10.0, 10.0, 10.0, 2));
|
||||
// With zero denominator on the first ready step we have no previous
|
||||
// value, so the indicator stays unset.
|
||||
assert!(v.is_none(), "expected hold, got {v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(c, c + 1.0, c - 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Pgo::new(14).unwrap().batch(&candles);
|
||||
let mut b = Pgo::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = Pgo::new(5).unwrap();
|
||||
for i in 0..20 {
|
||||
p.update(candle(10.0, 11.0, 9.0, i));
|
||||
}
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.update(candle(10.0, 11.0, 9.0, 0)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Relative Vigor Index (RVI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Relative Vigor Index — Donald Dorsey's ratio of intra-bar drive (close − open)
|
||||
/// to intra-bar range (high − low), averaged over a `period`-bar window.
|
||||
///
|
||||
/// The reading is `SMA(close − open, period) / SMA(high − low, period)`. A
|
||||
/// positive value means the average bar in the window closed above where it
|
||||
/// opened (bullish "vigor"); a negative value means the average closed below.
|
||||
/// The denominator's rolling-window SMA can fall to zero on a perfectly flat
|
||||
/// stretch, in which case the recurrence is undefined and the indicator holds
|
||||
/// its previous value.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Rvi};
|
||||
///
|
||||
/// let mut rvi = Rvi::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let o = 100.0 + f64::from(i);
|
||||
/// let c = o + 0.5;
|
||||
/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
|
||||
/// last = rvi.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rvi {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_num: f64,
|
||||
sum_den: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rvi {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_num: 0.0,
|
||||
sum_den: 0.0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rvi {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let num = candle.close - candle.open;
|
||||
let den = candle.high - candle.low;
|
||||
if self.window.len() == self.period {
|
||||
let (old_n, old_d) = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum_num -= old_n;
|
||||
self.sum_den -= old_d;
|
||||
}
|
||||
self.window.push_back((num, den));
|
||||
self.sum_num += num;
|
||||
self.sum_den += den;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
if self.sum_den <= 0.0 {
|
||||
// Window of perfectly flat (zero-range) bars: ratio undefined.
|
||||
// Hold the previous value rather than emitting NaN / inf.
|
||||
return self.current;
|
||||
}
|
||||
let value = self.sum_num / self.sum_den;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_num = 0.0;
|
||||
self.sum_den = 0.0;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RVI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Rvi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut r = Rvi::new(10).unwrap();
|
||||
assert_eq!(r.period(), 10);
|
||||
assert_eq!(r.warmup_period(), 10);
|
||||
assert_eq!(r.name(), "RVI");
|
||||
assert_eq!(r.value(), None);
|
||||
for i in 0..10 {
|
||||
r.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
assert!(r.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_period_2() {
|
||||
// Two bars with (open, high, low, close) = (10, 11, 9, 10.5) and
|
||||
// (10.5, 11.5, 10, 11). Per bar:
|
||||
// num1 = 0.5, num2 = 0.5; sum = 1.0
|
||||
// den1 = 2.0, den2 = 1.5; sum = 3.5
|
||||
// RVI = 1.0 / 3.5 ≈ 0.2857142857
|
||||
let mut r = Rvi::new(2).unwrap();
|
||||
assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
|
||||
let v = r.update(candle(10.5, 11.5, 10.0, 11.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 1.0 / 3.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut r = Rvi::new(3).unwrap();
|
||||
for i in 0..2 {
|
||||
assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
|
||||
}
|
||||
assert!(r.update(candle(10.5, 11.5, 10.0, 11.0, 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
// Every bar closes above its open and has a non-zero range: RVI > 0.
|
||||
let mut r = Rvi::new(5).unwrap();
|
||||
for i in 0..10 {
|
||||
let o = 10.0 + f64::from(i);
|
||||
let c = o + 0.5;
|
||||
r.update(candle(o, c + 0.2, o - 0.2, c, i64::from(i)));
|
||||
}
|
||||
let v = r.value().unwrap();
|
||||
assert!(v > 0.0, "uptrend RVI should be positive: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_window_holds_value() {
|
||||
// Window of perfectly flat bars (high == low): ratio undefined,
|
||||
// indicator holds.
|
||||
let mut r = Rvi::new(3).unwrap();
|
||||
r.update(candle(10.0, 10.0, 10.0, 10.0, 0));
|
||||
r.update(candle(10.0, 10.0, 10.0, 10.0, 1));
|
||||
assert_eq!(r.update(candle(10.0, 10.0, 10.0, 10.0, 2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40_i64)
|
||||
.map(|i| {
|
||||
let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||
let c = o + (i as f64 * 0.1).cos();
|
||||
candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Rvi::new(10).unwrap().batch(&candles);
|
||||
let mut b = Rvi::new(10).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = Rvi::new(5).unwrap();
|
||||
for i in 0..10 {
|
||||
r.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Stochastic Momentum Index (SMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// William Blau's Stochastic Momentum Index — a doubly-smoothed,
|
||||
/// `±100`-bounded oscillator built from the close's distance to the centre
|
||||
/// of the recent high-low range.
|
||||
///
|
||||
/// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`,
|
||||
/// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is
|
||||
/// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s,
|
||||
/// then combined into the bounded reading:
|
||||
///
|
||||
/// ```text
|
||||
/// D_smoothed = EMA(EMA(d, d_period), d2_period)
|
||||
/// HL_smoothed = EMA(EMA(R, d_period), d2_period)
|
||||
/// SMI = 100 · D_smoothed / (HL_smoothed / 2)
|
||||
/// ```
|
||||
///
|
||||
/// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra
|
||||
/// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left
|
||||
/// to the consumer via `Chain` / their own `Ema`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Smi};
|
||||
///
|
||||
/// let mut smi = Smi::new(5, 3, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap();
|
||||
/// last = smi.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Smi {
|
||||
period: usize,
|
||||
d_period: usize,
|
||||
d2_period: usize,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
ema_d1: Ema,
|
||||
ema_d2: Ema,
|
||||
ema_r1: Ema,
|
||||
ema_r2: Ema,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Smi {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero.
|
||||
pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result<Self> {
|
||||
if period == 0 || d_period == 0 || d2_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
d_period,
|
||||
d2_period,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
ema_d1: Ema::new(d_period)?,
|
||||
ema_d2: Ema::new(d2_period)?,
|
||||
ema_r1: Ema::new(d_period)?,
|
||||
ema_r2: Ema::new(d2_period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 3, 3).expect("classic SMI parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, d_period, d2_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.period, self.d_period, self.d2_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Smi {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.highs.len() == self.period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let center = f64::midpoint(hh, ll);
|
||||
let displacement = candle.close - center;
|
||||
let range = hh - ll;
|
||||
|
||||
// Feed every EMA on every candle so both stacks warm in parallel —
|
||||
// gating the range stack behind the displacement stack would starve
|
||||
// it by one input.
|
||||
let d1 = self.ema_d1.update(displacement);
|
||||
let r1 = self.ema_r1.update(range);
|
||||
let d2 = d1.and_then(|x| self.ema_d2.update(x));
|
||||
let r2 = r1.and_then(|x| self.ema_r2.update(x));
|
||||
let (d2, r2) = (d2?, r2?);
|
||||
|
||||
if r2 <= 0.0 {
|
||||
// Window where the smoothed range collapses to zero: the formula
|
||||
// is undefined. Hold the previous reading rather than emit inf.
|
||||
return self.current;
|
||||
}
|
||||
let value = 100.0 * d2 / (r2 / 2.0);
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
self.ema_d1.reset();
|
||||
self.ema_d2.reset();
|
||||
self.ema_r1.reset();
|
||||
self.ema_r2.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The high-low window needs `period` candles; then both EMA stacks
|
||||
// need `d_period + d2_period - 1` more values to fully warm up.
|
||||
self.period + self.d_period + self.d2_period - 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let smi = Smi::new(5, 3, 3).unwrap();
|
||||
assert_eq!(smi.periods(), (5, 3, 3));
|
||||
assert_eq!(smi.warmup_period(), 9);
|
||||
assert_eq!(smi.name(), "SMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
let smi = Smi::classic();
|
||||
assert_eq!(smi.periods(), (5, 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_high_pushes_toward_plus_100() {
|
||||
// Every candle's close equals its high in a rising series: the
|
||||
// displacement is at the top of the range every bar, so SMI sits in
|
||||
// the strongly positive region. After enough double-smoothing it
|
||||
// approaches the upper bound.
|
||||
let mut smi = Smi::classic();
|
||||
let mut last = None;
|
||||
for i in 0..80 {
|
||||
let h = 100.0 + f64::from(i);
|
||||
let l = h - 2.0;
|
||||
last = smi.update(candle(h, l, h, i64::from(i)));
|
||||
}
|
||||
let v = last.expect("SMI is warm");
|
||||
assert!(
|
||||
v > 50.0,
|
||||
"close-at-high series should drive SMI well above 0: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_low_pushes_toward_minus_100() {
|
||||
let mut smi = Smi::classic();
|
||||
let mut last = None;
|
||||
for i in 0..80 {
|
||||
let h = 100.0 - f64::from(i);
|
||||
let l = h - 2.0;
|
||||
last = smi.update(candle(h, l, l, i64::from(i)));
|
||||
}
|
||||
let v = last.expect("SMI is warm");
|
||||
assert!(
|
||||
v < -50.0,
|
||||
"close-at-low series should drive SMI well below 0: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut smi = Smi::new(3, 2, 2).unwrap();
|
||||
// period 3 + d 2 + d2 2 - 2 = 5.
|
||||
assert_eq!(smi.warmup_period(), 5);
|
||||
let mut got = None;
|
||||
for i in 0..5 {
|
||||
got = smi.update(candle(11.0, 9.0, 10.0, i));
|
||||
}
|
||||
assert!(got.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_close_yields_zero_displacement() {
|
||||
// Every close is exactly at the centre of the range -> displacement
|
||||
// is 0 every bar -> SMI converges to 0.
|
||||
let mut smi = Smi::classic();
|
||||
let mut last = None;
|
||||
for i in 0..60 {
|
||||
// High and low straddle a constant close.
|
||||
last = smi.update(candle(11.0, 9.0, 10.0, i));
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(c + 1.0, c - 1.0, c, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Smi::classic().batch(&candles);
|
||||
let mut b = Smi::classic();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut smi = Smi::classic();
|
||||
for i in 0..40 {
|
||||
smi.update(candle(11.0, 9.0, 10.0, i));
|
||||
}
|
||||
assert!(smi.is_ready());
|
||||
smi.reset();
|
||||
assert!(!smi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_holds_previous_value() {
|
||||
// High == low on every bar -> instantaneous range is zero, the
|
||||
// EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup
|
||||
// and the indicator must hold its previous value (None here, since
|
||||
// r2 was zero from the very first warm bar) rather than divide by
|
||||
// zero.
|
||||
let mut smi = Smi::new(3, 2, 2).unwrap();
|
||||
// warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars.
|
||||
for i in 0..7 {
|
||||
let v = smi.update(candle(10.0, 10.0, 10.0, i));
|
||||
assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,14 +48,15 @@ pub use indicators::{
|
||||
AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower,
|
||||
BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator,
|
||||
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo,
|
||||
EaseOfMovement, Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, Jma, Kama, Keltner,
|
||||
KeltnerOutput, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, MacdOutput,
|
||||
MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc,
|
||||
RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend,
|
||||
SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex,
|
||||
UltimateOscillator, VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput,
|
||||
Vwap, Vwma, WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3,
|
||||
ChandelierExitOutput, ChoppinessIndex, Cmo, ConnorsRsi, Coppock, Dema, Donchian,
|
||||
DonchianOutput, Dpo, EaseOfMovement, Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma,
|
||||
Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle,
|
||||
LinRegSlope, LinearRegression, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic,
|
||||
MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pgo, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Rvi,
|
||||
Sma, Smi, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput,
|
||||
Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma,
|
||||
WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3,
|
||||
};
|
||||
pub use ohlcv::{Candle, Tick};
|
||||
pub use traits::{BatchExt, Chain, Indicator};
|
||||
|
||||
@@ -20,7 +20,7 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through
|
||||
use std::hint::black_box;
|
||||
use wickra::{
|
||||
Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, Indicator, Jma, MacdIndicator,
|
||||
McGinleyDynamic, Obv, Rsi, Sma, Stochastic, Vidya, Wma,
|
||||
McGinleyDynamic, Obv, Pgo, Rsi, Rvi, Sma, Stochastic, Vidya, Wma,
|
||||
};
|
||||
use wickra_data::csv::CandleReader;
|
||||
|
||||
@@ -151,6 +151,8 @@ fn benches(c: &mut Criterion) {
|
||||
bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap());
|
||||
bench_candle_input(c, "stochastic", &candles, Stochastic::classic);
|
||||
bench_candle_input(c, "obv", &candles, Obv::new);
|
||||
bench_candle_input(c, "rvi", &candles, || Rvi::new(10).unwrap());
|
||||
bench_candle_input(c, "pgo", &candles, || Pgo::new(14).unwrap());
|
||||
}
|
||||
|
||||
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
Alma, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, Frama, HistoricalVolatility, Hma,
|
||||
Indicator, Jma, Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator,
|
||||
McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, StochRsi, T3, Tema, Trima, Trix,
|
||||
Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, Zlema,
|
||||
Alma, BatchExt, BollingerBands, Cmo, ConnorsRsi, Coppock, Dema, Dpo, Ema, Frama,
|
||||
HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegSlope,
|
||||
LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev,
|
||||
StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore,
|
||||
Zlema,
|
||||
};
|
||||
|
||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||
@@ -75,6 +76,18 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| LinRegAngle::new(14).unwrap(), &data);
|
||||
drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data);
|
||||
drive(|| ZScore::new(14).unwrap(), &data);
|
||||
drive(|| LaguerreRsi::new(0.5).unwrap(), &data);
|
||||
drive(|| ConnorsRsi::classic(), &data);
|
||||
|
||||
// KST is scalar-input but emits `KstOutput`, so it bypasses the generic
|
||||
// `drive` helper. Streaming + batch are still both exercised.
|
||||
{
|
||||
let mut kst = Kst::classic();
|
||||
for &x in &data {
|
||||
let _ = kst.update(x);
|
||||
}
|
||||
let _ = Kst::classic().batch(&data);
|
||||
}
|
||||
|
||||
// MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the
|
||||
// generic `drive` helper above. Streaming + batch are still both exercised.
|
||||
|
||||
@@ -26,8 +26,8 @@ use wickra_core::{
|
||||
AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, AtrTrailingStop,
|
||||
AwesomeOscillator, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator,
|
||||
ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement,
|
||||
Evwma, ForceIndex, Indicator, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Psar,
|
||||
RollingVwap,
|
||||
Evwma, ForceIndex, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Pgo,
|
||||
Psar, RollingVwap, Rvi, Smi,
|
||||
Stochastic, SuperTrend, TrueRange, TypicalPrice, UltimateOscillator, VolumePriceTrend, Vortex,
|
||||
Vwap, Vwma, WeightedClose, WilliamsR,
|
||||
};
|
||||
@@ -97,6 +97,10 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
|
||||
// --- Momentum & Oscillators ---
|
||||
drive(|| Cci::new(20).unwrap(), &candles);
|
||||
drive(|| Rvi::new(10).unwrap(), &candles);
|
||||
drive(|| Inertia::new(14, 20).unwrap(), &candles);
|
||||
drive(|| Pgo::new(14).unwrap(), &candles);
|
||||
drive(|| Smi::classic(), &candles);
|
||||
drive(|| WilliamsR::new(14).unwrap(), &candles);
|
||||
drive(|| AwesomeOscillator::new(5, 34).unwrap(), &candles);
|
||||
drive(|| AcceleratorOscillator::new(5, 34, 5).unwrap(), &candles);
|
||||
|
||||
Reference in New Issue
Block a user