feat: add 9 Risk / Performance indicators (B18) (#218)

Adds nine risk/performance metrics to the existing **Risk / Performance** family, all consuming a per-period return series (`f64` in, `f64` out). Indicator count **498 → 507**.

## Indicators

Single-param (`new(period)`, macro bindings):
- **SterlingRatio** — mean return over average drawdown of the equity curve.
- **BurkeRatio** — return over root-sum-squared drawdowns.
- **MartinRatio** — Ulcer Performance Index; return over RMS percentage drawdown.
- **TailRatio** — 95th percentile over the absolute 5th percentile return.
- **KRatio** — Kestner; equity-curve OLS slope over the standard error of that slope.
- **CommonSenseRatio** — tail ratio times gain-to-pain.
- **GainToPainRatio** — sum of returns over the sum of absolute losses.

Multi-param (hand-written Python/Node bindings, variadic WASM macro):
- **UpsidePotentialRatio** — `new(period, mar)`; upside mean over downside deviation (Sortino philosophy).
- **M2Measure** — `new(period, risk_free, benchmark_stddev)`; Modigliani M², Sharpe rescaled into benchmark return units.

## Touchpoints
Core modules + unit tests, `mod.rs`/`lib.rs` wiring, Python/Node/WASM bindings (`index.d.ts`/`index.js` regenerated), fuzz drive lines, Python `SCALAR` registry + Node factories, CHANGELOG, and the indicator counters.

## Verification
- `cargo test -p wickra-core --lib` — 4149 passed
- `cargo test -p wickra-core --doc` — 457 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- `npm test` (node) — 577 passed
- `pytest` (python) — 947 passed
This commit is contained in:
kingchenc
2026-06-08 13:23:01 +02:00
committed by GitHub
parent fc6f619550
commit bca61322b5
23 changed files with 2843 additions and 58 deletions
+18
View File
@@ -25,6 +25,15 @@ from __future__ import annotations
from ._wickra import (
__version__,
M2Measure,
UpsidePotentialRatio,
GainToPainRatio,
CommonSenseRatio,
KRatio,
TailRatio,
MartinRatio,
BurkeRatio,
SterlingRatio,
AUTOCORRPGRAM,
EVENBETTERSINE,
BANDPASS,
@@ -552,6 +561,15 @@ from ._wickra import (
)
__all__ = [
"M2Measure",
"UpsidePotentialRatio",
"GainToPainRatio",
"CommonSenseRatio",
"KRatio",
"TailRatio",
"MartinRatio",
"BurkeRatio",
"SterlingRatio",
"AUTOCORRPGRAM",
"EVENBETTERSINE",
"BANDPASS",
+470
View File
@@ -4136,6 +4136,350 @@ impl PyAdaptiveCci {
}
}
// ============================== SterlingRatio ==============================
#[pyclass(name = "SterlingRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySterlingRatio {
inner: wc::SterlingRatio,
}
#[pymethods]
impl PySterlingRatio {
#[new]
#[pyo3(signature = (period=12))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::SterlingRatio::new(period).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(self.inner.batch_nan(s).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!("SterlingRatio(period={})", self.inner.period())
}
}
// ============================== BurkeRatio ==============================
#[pyclass(name = "BurkeRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyBurkeRatio {
inner: wc::BurkeRatio,
}
#[pymethods]
impl PyBurkeRatio {
#[new]
#[pyo3(signature = (period=12))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BurkeRatio::new(period).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(self.inner.batch_nan(s).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!("BurkeRatio(period={})", self.inner.period())
}
}
// ============================== MartinRatio ==============================
#[pyclass(name = "MartinRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMartinRatio {
inner: wc::MartinRatio,
}
#[pymethods]
impl PyMartinRatio {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MartinRatio::new(period).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(self.inner.batch_nan(s).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!("MartinRatio(period={})", self.inner.period())
}
}
// ============================== TailRatio ==============================
#[pyclass(name = "TailRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTailRatio {
inner: wc::TailRatio,
}
#[pymethods]
impl PyTailRatio {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TailRatio::new(period).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(self.inner.batch_nan(s).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!("TailRatio(period={})", self.inner.period())
}
}
// ============================== KRatio ==============================
#[pyclass(name = "KRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyKRatio {
inner: wc::KRatio,
}
#[pymethods]
impl PyKRatio {
#[new]
#[pyo3(signature = (period=30))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KRatio::new(period).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(self.inner.batch_nan(s).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!("KRatio(period={})", self.inner.period())
}
}
// ============================== CommonSenseRatio ==============================
#[pyclass(
name = "CommonSenseRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyCommonSenseRatio {
inner: wc::CommonSenseRatio,
}
#[pymethods]
impl PyCommonSenseRatio {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::CommonSenseRatio::new(period).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(self.inner.batch_nan(s).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!("CommonSenseRatio(period={})", self.inner.period())
}
}
// ============================== GainToPainRatio ==============================
#[pyclass(
name = "GainToPainRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyGainToPainRatio {
inner: wc::GainToPainRatio,
}
#[pymethods]
impl PyGainToPainRatio {
#[new]
#[pyo3(signature = (period=12))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GainToPainRatio::new(period).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(self.inner.batch_nan(s).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!("GainToPainRatio(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -21908,6 +22252,123 @@ impl PyTickIndex {
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(
name = "UpsidePotentialRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyUpsidePotentialRatio {
inner: wc::UpsidePotentialRatio,
}
#[pymethods]
impl PyUpsidePotentialRatio {
#[new]
#[pyo3(signature = (period, mar=0.0))]
fn new(period: usize, mar: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::UpsidePotentialRatio::new(period, mar).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(self.inner.batch_nan(slice).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn mar(&self) -> f64 {
self.inner.mar()
}
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!(
"UpsidePotentialRatio(period={}, mar={})",
self.inner.period(),
self.inner.mar()
)
}
}
#[pyclass(name = "M2Measure", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyM2Measure {
inner: wc::M2Measure,
}
#[pymethods]
impl PyM2Measure {
#[new]
#[pyo3(signature = (period, risk_free, benchmark_stddev))]
fn new(period: usize, risk_free: f64, benchmark_stddev: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::M2Measure::new(period, risk_free, benchmark_stddev).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(self.inner.batch_nan(slice).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
#[getter]
fn benchmark_stddev(&self) -> f64 {
self.inner.benchmark_stddev()
}
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!(
"M2Measure(period={}, risk_free={}, benchmark_stddev={})",
self.inner.period(),
self.inner.risk_free(),
self.inner.benchmark_stddev()
)
}
}
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySharpeRatio {
@@ -25881,5 +26342,14 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyFryPanBottom>()?;
m.add_class::<PyDumplingTop>()?;
m.add_class::<PyNewPriceLines>()?;
m.add_class::<PySterlingRatio>()?;
m.add_class::<PyBurkeRatio>()?;
m.add_class::<PyMartinRatio>()?;
m.add_class::<PyTailRatio>()?;
m.add_class::<PyKRatio>()?;
m.add_class::<PyCommonSenseRatio>()?;
m.add_class::<PyGainToPainRatio>()?;
m.add_class::<PyUpsidePotentialRatio>()?;
m.add_class::<PyM2Measure>()?;
Ok(())
}
@@ -45,6 +45,15 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.M2Measure, (20, 0.0, 0.02)),
(ta.UpsidePotentialRatio, (20, 0.0)),
(ta.GainToPainRatio, (12,)),
(ta.CommonSenseRatio, (20,)),
(ta.KRatio, (30,)),
(ta.TailRatio, (20,)),
(ta.MartinRatio, (14,)),
(ta.BurkeRatio, (12,)),
(ta.SterlingRatio, (12,)),
(ta.AUTOCORRPGRAM, (10, 48)),
(ta.EVENBETTERSINE, (40, 10)),
(ta.BANDPASS, (20, 0.3)),