3ea0f12b7a
* feat(rvi): add Relative Volatility Index
Donald Dorsey's RSI-shaped volatility gauge. Partitions the rolling
population standard deviation of close into "up" samples (close rose
since the previous bar) and "down" samples (close fell), Wilder-smooths
each side, and reports 100 * AvgUp / (AvgUp + AvgDown). Output bounded
on [0, 100]; saturates at 100 in pure uptrends, 0 in pure downtrends,
and falls back to 50 on a completely flat series (same undefined-RS
convention as RSI).
Single period parameter (default 10) drives both the stddev window and
the Wilder smoothing constant. First emit lands at index 2*period - 2
(2*period - 1 bars are needed: period to fill the stddev window plus
period - 1 to seed the Wilder averages, overlapping by one bar).
Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py +
test_new_indicators SCALAR + test_known_values uptrend reference,
RviNode + index.d.ts/index.js + indicators.test.js factory +
reference, WasmRvi via scalar macro, scalar-fuzz target, bench_scalar
entry, README + CHANGELOG.
* feat(parkinson): add Parkinson Volatility
Michael Parkinson's (1980) high-low realised volatility estimator.
Under a driftless Geometric-Brownian-Motion assumption, the extreme
range of a bar carries roughly 5x the variance information of the
close-to-close estimator, so for a given statistical efficiency
Parkinson needs five times fewer samples.
Formula:
sigma^2 = (1 / (4n * ln 2)) * Sum_{i=1..n} (ln(H_i / L_i))^2
out = sqrt(sigma^2) * sqrt(trading_periods) * 100
The output is annualised to a percent in the same style as
HistoricalVolatility (pass `trading_periods = 1` for the raw per-bar
sigma * 100 figure). Two parameters: `period` (default 20) for the
rolling window, `trading_periods` (default 252) for the annualisation
factor. First emit at index `period - 1`.
Touchpoints: parkinson.rs + mod.rs + lib.rs re-export,
PyParkinsonVolatility + __init__.py + test_new_indicators CANDLE_SCALAR
+ test_known_values zero-range reference, ParkinsonVolatilityNode +
index.d.ts/index.js + indicators.test.js factory + reference,
WasmParkinsonVolatility hand-rolled, candle-fuzz target,
bench_candle_input entry, README + CHANGELOG.
* feat(garman-klass): add Garman-Klass Volatility
Garman & Klass (1980) OHLC realised-volatility estimator. Extends
Parkinson's high-low estimator with an open-to-close term, lifting
statistical efficiency from ~5x to ~7.4x relative to close-to-close
stddev under driftless Geometric Brownian Motion.
Formula (per bar):
s_t = 0.5 * (ln(H_t / L_t))^2 - (2*ln(2) - 1) * (ln(C_t / O_t))^2
out = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100
The per-bar sample can be marginally negative when the bar has a small
range relative to its open-to-close move; a max(., 0) clamp on the
rolling mean absorbs that and the FP cancellation noise before the
square root.
Still biased on data with meaningful overnight drift -- use Yang-Zhang
when gaps matter. Defaults: `period = 20`, `trading_periods = 252`
(annualised percent, same convention as HistoricalVolatility).
Touchpoints: garman_klass.rs + mod.rs + lib.rs re-export,
PyGarmanKlassVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
GarmanKlassVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmGarmanKlassVolatility hand-rolled,
candle-fuzz target, bench_candle_input entry, README + CHANGELOG.
* feat(rogers-satchell): add Rogers-Satchell Volatility
Rogers, Satchell & Yoon (1994) OHLC realised-volatility estimator.
Unlike Garman-Klass, the per-bar sample is exact under arbitrary
Brownian drift -- the drift component cancels algebraically.
Formula (per bar):
s_t = ln(H_t / C_t) * ln(H_t / O_t) + ln(L_t / C_t) * ln(L_t / O_t)
out = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100
Each per-bar sample is also non-negative by construction: with
`Candle::new` guaranteeing H >= max(O, L, C) and L <= min(O, H, C), the
four log factors have predictable signs (ln(H/.) >= 0, ln(L/.) <= 0),
so both products contribute >= 0. The max(., 0) clamp on the rolling
mean is only there to absorb FP cancellation.
Defaults: `period = 20`, `trading_periods = 252` (annualised percent,
same convention as HistoricalVolatility / Parkinson / Garman-Klass).
Touchpoints: rogers_satchell.rs + mod.rs + lib.rs re-export,
PyRogersSatchellVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
RogersSatchellVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmRogersSatchellVolatility hand-rolled,
candle-fuzz target, bench_candle_input entry, README + CHANGELOG.
* feat(yang-zhang): add Yang-Zhang Volatility
Yang & Zhang (2000) drift- and gap-robust OHLC realised-volatility
estimator. Combines three independent components into a single estimate
with minimum variance:
overnight = sample_var(ln(O_t / C_{t-1})) over n bars (close-to-open)
open_close = sample_var(ln(C_t / O_t)) over n bars
rs = mean(ln(H/C)*ln(H/O) + ln(L/C)*ln(L/O)) over n bars
sigma^2_YZ = overnight + k*open_close + (1-k)*rs
k = 0.34 / (1.34 + (n+1)/(n-1))
out = sqrt(max(sigma^2_YZ, 0)) * sqrt(trading_periods) * 100
The overnight and open-to-close variances use Bessel's correction (the
sample estimator, divisor n-1), same convention as
HistoricalVolatility. The blending factor `k` is the one that
minimises estimator variance under driftless Geometric Brownian Motion
with overnight gaps.
This is the gold-standard OHLC estimator for assets with both
close-to-open gaps and intraday drift: equities, futures, and any
market that does not trade continuously. For pure intraday data (where
O_t == C_{t-1} and the open-to-close return is constant), the
overnight and open-close terms vanish and the estimator collapses to
(1-k) * Rogers-Satchell -- this is the indicator's
intraday_data_collapses_to_rs_only unit test.
Period >= 2 (Bessel correction needs >= 2 samples). First emit at
index `period` (the (period+1)-th bar): one bar seeds prev_close, the
next `period` fill the rolling windows. Defaults: `period = 20`,
`trading_periods = 252`.
Touchpoints: yang_zhang.rs + mod.rs + lib.rs re-export,
PyYangZhangVolatility + __init__.py + test_new_indicators
CANDLE_SCALAR + test_known_values zero-movement reference,
YangZhangVolatilityNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmYangZhangVolatility hand-rolled, candle-fuzz
target, bench_candle_input entry, README + CHANGELOG.
* fix(rvi): rename to RviVolatility to avoid clash with family-02 RVI
Family 02 (PR #40) ships a separate `Rvi` struct for Relative Vigor
Index. The two indicators have nothing to do with each other beyond
sharing the acronym, so disambiguate by giving the volatility one a
longer name everywhere:
- Rust crate: `Rvi` -> `RviVolatility`
- Rust file: `rvi.rs` -> `rvi_volatility.rs`
- Python: `RVI` -> `RVIVolatility`
- Node: `RVI` -> `RVIVolatility`
- WASM: `RVI` -> `RVIVolatility`
Once the two PRs are both merged, callers get `wickra::Rvi` for Vigor
and `wickra::RviVolatility` for Volatility. The shorter `RVI` acronym
stays with the Momentum family per the existing wiki pages and the
implementation that shipped first.
Updates: rvi_volatility.rs (renamed), mod.rs, lib.rs re-export,
bindings/python/src/lib.rs + __init__.py + tests, bindings/node/src/lib.rs
+ index.d.ts + index.js + __tests__, bindings/wasm/src/lib.rs,
fuzz/fuzz_targets/indicator_update.rs, crates/wickra/benches/indicators.rs,
README family-table label, CHANGELOG entry.
* test(volatility): Rename test_rvi -> test_rvi_volatility + drop dead match arms
The Python test test_rvi_pure_uptrend_saturates_at_one_hundred was
calling ta.RVI() expecting the volatility version, but ta.RVI now
means Family 02's Relative Vigor Index (candle input). Renamed to
ta.RVIVolatility to match the binding rename done at merge time.
In all four OHLC volatility tests, the existing `match (r, a) { ...,
_ => panic!() }` arm is dead in passing runs (every aligned pair is
either (None, None) or (Some, Some)). Codecov flagged it as a patch
miss on each of parkinson / garman_klass / rogers_satchell /
yang_zhang. Refactored per CLAUDE.md cold-path guidance to
`assert_eq!(r.is_some(), a.is_some()); if let (Some, Some) ...`.
417 lines
14 KiB
Python
417 lines
14 KiB
Python
"""Streaming-vs-batch, shape and reference-value tests for the F1-F12 families.
|
|
|
|
Every indicator added since the original 25 is exercised here. The central
|
|
contract is the same as the rest of the suite: ``batch(...)`` must equal
|
|
repeated streaming ``update(...)`` across the whole warmup -> steady-state
|
|
transition, and batch shapes must match the input length.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import wickra as ta
|
|
|
|
|
|
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
|
|
"""Compare two float arrays treating NaN positions as equal."""
|
|
a = np.asarray(a, dtype=np.float64)
|
|
b = np.asarray(b, dtype=np.float64)
|
|
if a.shape != b.shape:
|
|
return False
|
|
both_nan = np.isnan(a) & np.isnan(b)
|
|
return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol))
|
|
|
|
|
|
@pytest.fixture
|
|
def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
"""Synthetic high / low / close / volume series, 200 bars."""
|
|
t = np.arange(200, dtype=np.float64)
|
|
close = 100.0 + np.sin(t * 0.15) * 8.0 + np.cos(t * 0.32) * 3.0
|
|
spread = 0.5 + np.abs(np.sin(t * 0.07))
|
|
high = close + spread
|
|
low = close - spread
|
|
volume = 1000.0 + (t % 7) * 50.0
|
|
return high, low, close, volume
|
|
|
|
|
|
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
|
|
|
SCALAR = [
|
|
(ta.SMMA, (14,)),
|
|
(ta.TRIMA, (20,)),
|
|
(ta.ZLEMA, (14,)),
|
|
(ta.ALMA, (9, 0.85, 6.0)),
|
|
(ta.McGinleyDynamic, (10,)),
|
|
(ta.FRAMA, (16,)),
|
|
(ta.VIDYA, (14, 9)),
|
|
(ta.JMA, (14, 0.0, 2)),
|
|
(ta.T3, (5, 0.7)),
|
|
(ta.MOM, (10,)),
|
|
(ta.CMO, (14,)),
|
|
(ta.TSI, (25, 13)),
|
|
(ta.PMO, (35, 20)),
|
|
(ta.StochRSI, (14, 14)),
|
|
(ta.PPO, (12, 26)),
|
|
(ta.APO, (12, 26)),
|
|
(ta.CFO, (14,)),
|
|
(ta.ElderImpulse, (13, 12, 26, 9)),
|
|
(ta.STC, (23, 50, 10, 0.5)),
|
|
(ta.DPO, (20,)),
|
|
(ta.Coppock, (14, 11, 10)),
|
|
(ta.StdDev, (20,)),
|
|
(ta.UlcerIndex, (14,)),
|
|
(ta.HistoricalVolatility, (20, 252)),
|
|
(ta.BollingerBandwidth, (20, 2.0)),
|
|
(ta.PercentB, (20, 2.0)),
|
|
(ta.LinearRegression, (14,)),
|
|
(ta.LinRegSlope, (14,)),
|
|
(ta.VerticalHorizontalFilter, (28,)),
|
|
(ta.ZScore, (20,)),
|
|
(ta.LinRegAngle, (14,)),
|
|
(ta.LaguerreRSI, (0.5,)),
|
|
(ta.ConnorsRSI, (3, 2, 100)),
|
|
(ta.RVIVolatility, (10,)),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR])
|
|
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
|
batch = cls(*args).batch(sine_prices)
|
|
assert batch.shape == sine_prices.shape
|
|
assert batch.dtype == np.float64
|
|
|
|
streamer = cls(*args)
|
|
streamed = []
|
|
for p in sine_prices:
|
|
v = streamer.update(float(p))
|
|
streamed.append(math.nan if v is None else float(v))
|
|
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
|
|
|
|
|
|
# --- Candle-input, single-output indicators -------------------------------
|
|
#
|
|
# Each entry is (factory, batch-call). Streaming always feeds the full
|
|
# 6-tuple candle; the batch helper takes only the columns it needs.
|
|
|
|
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),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"AroonOscillator": (
|
|
lambda: ta.AroonOscillator(14),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"NATR": (lambda: ta.NATR(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
|
"MassIndex": (lambda: ta.MassIndex(9, 25), lambda ind, h, l, c, v: ind.batch(h, l)),
|
|
"ADL": (lambda: ta.ADL(), lambda ind, h, l, c, v: ind.batch(h, l, c, v)),
|
|
"VolumePriceTrend": (
|
|
lambda: ta.VolumePriceTrend(),
|
|
lambda ind, h, l, c, v: ind.batch(c, v),
|
|
),
|
|
"ChaikinMoneyFlow": (
|
|
lambda: ta.ChaikinMoneyFlow(20),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
|
),
|
|
"ChaikinOscillator": (
|
|
lambda: ta.ChaikinOscillator(3, 10),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
|
),
|
|
"ForceIndex": (
|
|
lambda: ta.ForceIndex(13),
|
|
lambda ind, h, l, c, v: ind.batch(c, v),
|
|
),
|
|
"EaseOfMovement": (
|
|
lambda: ta.EaseOfMovement(14),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
|
),
|
|
"AtrTrailingStop": (
|
|
lambda: ta.AtrTrailingStop(14, 3.0),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"TypicalPrice": (
|
|
lambda: ta.TypicalPrice(),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"MedianPrice": (
|
|
lambda: ta.MedianPrice(),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"WeightedClose": (
|
|
lambda: ta.WeightedClose(),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"AcceleratorOscillator": (
|
|
lambda: ta.AcceleratorOscillator(5, 34, 5),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"AwesomeOscillatorHistogram": (
|
|
lambda: ta.AwesomeOscillatorHistogram(5, 34, 5),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"BalanceOfPower": (
|
|
# The streaming 6-tuple feeds open == close, so batch matches with
|
|
# the close column standing in for open.
|
|
lambda: ta.BalanceOfPower(),
|
|
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
|
),
|
|
"ChoppinessIndex": (
|
|
lambda: ta.ChoppinessIndex(14),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"TrueRange": (
|
|
lambda: ta.TrueRange(),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"ChaikinVolatility": (
|
|
lambda: ta.ChaikinVolatility(10, 10),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"ParkinsonVolatility": (
|
|
lambda: ta.ParkinsonVolatility(20, 252),
|
|
lambda ind, h, l, c, v: ind.batch(h, l),
|
|
),
|
|
"GarmanKlassVolatility": (
|
|
# The streaming 6-tuple feeds open == close, so batch matches with
|
|
# the close column standing in for open.
|
|
lambda: ta.GarmanKlassVolatility(20, 252),
|
|
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
|
),
|
|
"RogersSatchellVolatility": (
|
|
lambda: ta.RogersSatchellVolatility(20, 252),
|
|
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
|
),
|
|
"YangZhangVolatility": (
|
|
lambda: ta.YangZhangVolatility(20, 252),
|
|
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
|
),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CANDLE_SCALAR))
|
|
def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
|
high, low, close, volume = ohlcv
|
|
make, batch_call = CANDLE_SCALAR[name]
|
|
|
|
batch = batch_call(make(), high, low, close, volume)
|
|
assert batch.shape == close.shape
|
|
|
|
streamer = make()
|
|
streamed = []
|
|
for i in range(close.size):
|
|
candle = (
|
|
float(close[i]),
|
|
float(high[i]),
|
|
float(low[i]),
|
|
float(close[i]),
|
|
float(volume[i]),
|
|
i,
|
|
)
|
|
v = streamer.update(candle)
|
|
streamed.append(math.nan if v is None else float(v))
|
|
assert _eq_nan(batch, np.array(streamed, dtype=np.float64)), f"{name} mismatch"
|
|
|
|
|
|
# --- Candle-input, multi-output indicators --------------------------------
|
|
|
|
MULTI = {
|
|
"Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
|
"SuperTrend": (
|
|
lambda: ta.SuperTrend(10, 3.0),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"ChandelierExit": (
|
|
lambda: ta.ChandelierExit(22, 3.0),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
"ChandeKrollStop": (
|
|
lambda: ta.ChandeKrollStop(10, 1.0, 9),
|
|
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
|
),
|
|
}
|
|
|
|
# --- 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):
|
|
high, low, close, volume = ohlcv
|
|
make, batch_call = MULTI[name]
|
|
|
|
batch = batch_call(make(), high, low, close, volume)
|
|
assert batch.shape == (close.size, 2)
|
|
|
|
streamer = make()
|
|
rows = []
|
|
for i in range(close.size):
|
|
candle = (
|
|
float(close[i]),
|
|
float(high[i]),
|
|
float(low[i]),
|
|
float(close[i]),
|
|
float(volume[i]),
|
|
i,
|
|
)
|
|
v = streamer.update(candle)
|
|
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"
|
|
|
|
|
|
@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"
|
|
|
|
|
|
# --- ZeroLagMACD (scalar input, 3-tuple output: macd / signal / histogram) -
|
|
|
|
|
|
def test_zero_lag_macd_streaming_matches_batch(ohlcv):
|
|
_, _, close, _ = ohlcv
|
|
batch = ta.ZeroLagMACD(12, 26, 9).batch(close)
|
|
assert batch.shape == (close.size, 3)
|
|
|
|
streamer = ta.ZeroLagMACD(12, 26, 9)
|
|
rows = []
|
|
for p in close:
|
|
v = streamer.update(float(p))
|
|
rows.append([math.nan, math.nan, math.nan] if v is None else list(v))
|
|
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), "ZeroLagMACD mismatch"
|
|
|
|
|
|
# --- Alligator (3-tuple output) -------------------------------------------
|
|
|
|
|
|
def test_alligator_streaming_matches_batch(ohlcv):
|
|
high, low, _, _ = ohlcv
|
|
alligator = ta.Alligator(13, 8, 5)
|
|
batch = alligator.batch(high, low)
|
|
assert batch.shape == (high.size, 3)
|
|
|
|
streamer = ta.Alligator(13, 8, 5)
|
|
rows = []
|
|
for i in range(high.size):
|
|
candle = (float(low[i]), float(high[i]), float(low[i]), float(low[i]), 0.0, i)
|
|
v = streamer.update(candle)
|
|
rows.append([math.nan, math.nan, math.nan] if v is None else list(v))
|
|
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), "Alligator mismatch"
|
|
|
|
|
|
# --- Reference values -----------------------------------------------------
|
|
|
|
|
|
def test_typical_price_reference():
|
|
# (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
|
|
assert ta.TypicalPrice().update((9.0, 12.0, 6.0, 9.0, 1.0, 0)) == pytest.approx(9.0)
|
|
|
|
|
|
def test_median_price_reference():
|
|
# (high + low) / 2 = (12 + 8) / 2 = 10.
|
|
assert ta.MedianPrice().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(10.0)
|
|
|
|
|
|
def test_weighted_close_reference():
|
|
# (high + low + 2*close) / 4 = (12 + 8 + 22) / 4 = 10.5.
|
|
assert ta.WeightedClose().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(
|
|
10.5
|
|
)
|
|
|
|
|
|
def test_chaikin_money_flow_reference():
|
|
cmf = ta.ChaikinMoneyFlow(2)
|
|
assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None
|
|
assert cmf.update((10.0, 12.0, 8.0, 10.0, 100.0, 1)) == pytest.approx(0.5)
|
|
|
|
|
|
def test_linear_regression_reference():
|
|
out = ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0]))
|
|
assert math.isnan(out[0]) and math.isnan(out[1])
|
|
assert out[2] == pytest.approx(8.0)
|
|
|
|
|
|
def test_linreg_slope_reference():
|
|
out = ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0]))
|
|
assert math.isnan(out[0]) and math.isnan(out[1])
|
|
assert out[2] == pytest.approx(4.0)
|
|
|
|
|
|
def test_balance_of_power_reference():
|
|
# (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
|
bop = ta.BalanceOfPower()
|
|
assert bop.update((10.0, 14.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.5)
|
|
|
|
|
|
def test_true_range_reference():
|
|
tr = ta.TrueRange()
|
|
assert tr.update((11.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(4.0)
|
|
assert tr.update((9.5, 10.0, 9.0, 9.5, 1.0, 1)) == pytest.approx(2.0)
|
|
|
|
|
|
def test_linreg_angle_reference():
|
|
# A series rising by 1 per step has slope 1, and atan(1) = 45 degrees.
|
|
out = ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
|
|
assert out[4] == pytest.approx(45.0)
|
|
|
|
|
|
def test_z_score_reference():
|
|
# Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1.
|
|
out = ta.ZScore(2).batch(np.array([1.0, 3.0]))
|
|
assert math.isnan(out[0])
|
|
assert out[1] == pytest.approx(1.0)
|
|
|
|
|
|
# --- Lifecycle ------------------------------------------------------------
|
|
|
|
|
|
def test_new_indicators_expose_lifecycle():
|
|
instances = [make() for make, _ in CANDLE_SCALAR.values()]
|
|
instances += [make() for make, _ in MULTI.values()]
|
|
instances += [make() for make, _ in MULTI_SCALAR_INPUT.values()]
|
|
instances += [cls(*args) for cls, args in SCALAR]
|
|
instances.append(ta.Alligator(13, 8, 5))
|
|
instances.append(ta.ZeroLagMACD(12, 26, 9))
|
|
for ind in instances:
|
|
assert ind.is_ready() is False
|
|
assert ind.warmup_period() >= 1
|
|
ind.reset()
|
|
assert ind.is_ready() is False
|