feat(family-15): add 17 risk/performance metrics (#54)

* feat(family-15): add 17 risk/performance metrics

Implements Family 15 pragmatically as standard `Indicator`s instead of a
separate `wickra-metrics` crate. Input is scalar `f64` per bar — period
return, equity sample, or per-trade P&L depending on the metric.

Scalar `Indicator<f64>` (14):
- SharpeRatio(period, risk_free)
- SortinoRatio(period, mar)
- CalmarRatio(period)
- OmegaRatio(period, threshold)
- MaxDrawdown(period)          — rolling, peak-to-trough
- AverageDrawdown(period)
- DrawdownDuration             — cumulative, bars under water (u32 output)
- PainIndex(period)
- ValueAtRisk(period, confidence)
- ConditionalValueAtRisk(period, confidence)
- ProfitFactor(period)
- GainLossRatio(period)
- RecoveryFactor               — cumulative, net return / max drawdown
- KellyCriterion(period)

Two-series `Indicator<(f64, f64)>` for (asset, benchmark) returns (3):
- TreynorRatio(period, risk_free)
- InformationRatio(period)
- Alpha(period, risk_free)     — Jensen / CAPM

Touchpoints:
- 17 new files under `crates/wickra-core/src/indicators/`.
- `mod.rs` + `lib.rs` re-exports.
- Python bindings (`bindings/python/src/lib.rs`, `__init__.py`).
- Node bindings (`bindings/node/src/lib.rs`, `index.js`).
- WASM bindings (`bindings/wasm/src/lib.rs`).
- Fuzz: scalar metrics appended to `indicator_update.rs`; new
  `indicator_update_pair.rs` fuzz target for `(f64, f64)` indicators.
- Python tests: SCALAR + new PAIR parameter lists in `test_new_indicators.py`,
  reference-value cases in `test_known_values.py`.
- Node tests: scalar factories + new pair-factory block in
  `bindings/node/__tests__/indicators.test.js`.
- Benches: 5 Family-15 benches added in `crates/wickra/benches/indicators.rs`.
- Docs: README family-table row + counter (71 -> 88), CHANGELOG entry under
  [Unreleased].

Note: Family 12 (statistik-regression, PR #51) introduces
`node_pair_indicator!` and `wasm_pair_indicator!` macros for Pearson /
Beta / Spearman. Family 15 needs the same pair-input pattern but Family 12
is not yet in main, so the three pair wrappers below are written by hand
in this PR. When PR #51 lands, the trivial merge-conflict is resolved by
keeping the macros from Family 12 and re-using them for Treynor / IR /
Alpha (drop the three handwritten wrappers).

cargo check --workspace --all-features: green.

* fix(family-15): satisfy clippy doc_markdown / if_not_else / digit_grouping

* fix(family-15): unused TreynorRatio import, duplicate pairFactories, _eq_nan inf handling

* fix(family-15): node eq() handles matching infinities for ratio indicators

* test(family-15): cover cold paths flagged by codecov patch
This commit is contained in:
kingchenc
2026-05-26 20:44:21 +02:00
committed by GitHub
parent 55284a3042
commit 4e3c41ea80
34 changed files with 5727 additions and 73 deletions
+36
View File
@@ -235,6 +235,24 @@ from ._wickra import (
SpinningTop,
ThreeInside,
ThreeOutside,
# Risk / Performance
SharpeRatio,
SortinoRatio,
CalmarRatio,
OmegaRatio,
MaxDrawdown,
AverageDrawdown,
DrawdownDuration,
PainIndex,
ValueAtRisk,
ConditionalValueAtRisk,
ProfitFactor,
GainLossRatio,
RecoveryFactor,
KellyCriterion,
TreynorRatio,
InformationRatio,
Alpha,
)
__all__ = [
@@ -449,4 +467,22 @@ __all__ = [
"SpinningTop",
"ThreeInside",
"ThreeOutside",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
"CalmarRatio",
"OmegaRatio",
"MaxDrawdown",
"AverageDrawdown",
"DrawdownDuration",
"PainIndex",
"ValueAtRisk",
"ConditionalValueAtRisk",
"ProfitFactor",
"GainLossRatio",
"RecoveryFactor",
"KellyCriterion",
"TreynorRatio",
"InformationRatio",
"Alpha",
]
+911
View File
@@ -11157,6 +11157,899 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySharpeRatio {
inner: wc::SharpeRatio,
}
#[pymethods]
impl PySharpeRatio {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SharpeRatio::new(period, risk_free).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(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
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!(
"SharpeRatio(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
#[pyclass(name = "SortinoRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySortinoRatio {
inner: wc::SortinoRatio,
}
#[pymethods]
impl PySortinoRatio {
#[new]
#[pyo3(signature = (period, mar=0.0))]
fn new(period: usize, mar: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SortinoRatio::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(flatten(self.inner.batch(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!(
"SortinoRatio(period={}, mar={})",
self.inner.period(),
self.inner.mar()
)
}
}
#[pyclass(name = "CalmarRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCalmarRatio {
inner: wc::CalmarRatio,
}
#[pymethods]
impl PyCalmarRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::CalmarRatio::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("CalmarRatio(period={})", self.inner.period())
}
}
#[pyclass(name = "OmegaRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyOmegaRatio {
inner: wc::OmegaRatio,
}
#[pymethods]
impl PyOmegaRatio {
#[new]
#[pyo3(signature = (period, threshold=0.0))]
fn new(period: usize, threshold: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::OmegaRatio::new(period, threshold).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(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn threshold(&self) -> f64 {
self.inner.threshold()
}
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!(
"OmegaRatio(period={}, threshold={})",
self.inner.period(),
self.inner.threshold()
)
}
}
#[pyclass(name = "MaxDrawdown", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMaxDrawdown {
inner: wc::MaxDrawdown,
}
#[pymethods]
impl PyMaxDrawdown {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MaxDrawdown::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("MaxDrawdown(period={})", self.inner.period())
}
}
#[pyclass(
name = "AverageDrawdown",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAverageDrawdown {
inner: wc::AverageDrawdown,
}
#[pymethods]
impl PyAverageDrawdown {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AverageDrawdown::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("AverageDrawdown(period={})", self.inner.period())
}
}
#[pyclass(
name = "DrawdownDuration",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyDrawdownDuration {
inner: wc::DrawdownDuration,
}
#[pymethods]
impl PyDrawdownDuration {
#[new]
fn new() -> Self {
Self {
inner: wc::DrawdownDuration::new(),
}
}
fn update(&mut self, value: f64) -> Option<u32> {
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))?;
let out: Vec<f64> = self
.inner
.batch(slice)
.into_iter()
.map(|v| v.map_or(f64::NAN, f64::from))
.collect();
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 {
"DrawdownDuration()".to_string()
}
}
#[pyclass(name = "PainIndex", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPainIndex {
inner: wc::PainIndex,
}
#[pymethods]
impl PyPainIndex {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PainIndex::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("PainIndex(period={})", self.inner.period())
}
}
#[pyclass(name = "ValueAtRisk", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyValueAtRisk {
inner: wc::ValueAtRisk,
}
#[pymethods]
impl PyValueAtRisk {
#[new]
#[pyo3(signature = (period, confidence=0.95))]
fn new(period: usize, confidence: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::ValueAtRisk::new(period, confidence).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(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn confidence(&self) -> f64 {
self.inner.confidence()
}
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!(
"ValueAtRisk(period={}, confidence={})",
self.inner.period(),
self.inner.confidence()
)
}
}
#[pyclass(
name = "ConditionalValueAtRisk",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyConditionalValueAtRisk {
inner: wc::ConditionalValueAtRisk,
}
#[pymethods]
impl PyConditionalValueAtRisk {
#[new]
#[pyo3(signature = (period, confidence=0.95))]
fn new(period: usize, confidence: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::ConditionalValueAtRisk::new(period, confidence).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(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn confidence(&self) -> f64 {
self.inner.confidence()
}
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!(
"ConditionalValueAtRisk(period={}, confidence={})",
self.inner.period(),
self.inner.confidence()
)
}
}
#[pyclass(name = "ProfitFactor", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyProfitFactor {
inner: wc::ProfitFactor,
}
#[pymethods]
impl PyProfitFactor {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ProfitFactor::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("ProfitFactor(period={})", self.inner.period())
}
}
#[pyclass(name = "GainLossRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGainLossRatio {
inner: wc::GainLossRatio,
}
#[pymethods]
impl PyGainLossRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GainLossRatio::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("GainLossRatio(period={})", self.inner.period())
}
}
#[pyclass(
name = "RecoveryFactor",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRecoveryFactor {
inner: wc::RecoveryFactor,
}
#[pymethods]
impl PyRecoveryFactor {
#[new]
fn new() -> Self {
Self {
inner: wc::RecoveryFactor::new(),
}
}
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(flatten(self.inner.batch(slice)).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 {
"RecoveryFactor()".to_string()
}
}
#[pyclass(
name = "KellyCriterion",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyKellyCriterion {
inner: wc::KellyCriterion,
}
#[pymethods]
impl PyKellyCriterion {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KellyCriterion::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).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!("KellyCriterion(period={})", self.inner.period())
}
}
// --- Pair (asset, benchmark) indicators ---
#[pyclass(name = "TreynorRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTreynorRatio {
inner: wc::TreynorRatio,
}
#[pymethods]
impl PyTreynorRatio {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
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!(
"TreynorRatio(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
#[pyclass(
name = "InformationRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyInformationRatio {
inner: wc::InformationRatio,
}
#[pymethods]
impl PyInformationRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::InformationRatio::new(period).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).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!("InformationRatio(period={})", self.inner.period())
}
}
#[pyclass(name = "Alpha", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAlpha {
inner: wc::Alpha,
}
#[pymethods]
impl PyAlpha {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Alpha::new(period, risk_free).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
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!(
"Alpha(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
// ============================== Module ==============================
#[pymodule]
@@ -11364,5 +12257,23 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySpinningTop>()?;
m.add_class::<PyThreeInside>()?;
m.add_class::<PyThreeOutside>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
m.add_class::<PyCalmarRatio>()?;
m.add_class::<PyOmegaRatio>()?;
m.add_class::<PyMaxDrawdown>()?;
m.add_class::<PyAverageDrawdown>()?;
m.add_class::<PyDrawdownDuration>()?;
m.add_class::<PyPainIndex>()?;
m.add_class::<PyValueAtRisk>()?;
m.add_class::<PyConditionalValueAtRisk>()?;
m.add_class::<PyProfitFactor>()?;
m.add_class::<PyGainLossRatio>()?;
m.add_class::<PyRecoveryFactor>()?;
m.add_class::<PyKellyCriterion>()?;
m.add_class::<PyTreynorRatio>()?;
m.add_class::<PyInformationRatio>()?;
m.add_class::<PyAlpha>()?;
Ok(())
}
+127
View File
@@ -332,6 +332,133 @@ def test_obv_cumulative_known_sequence():
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
# --- Family 15: Risk / Performance ---------------------------------------
def test_sharpe_ratio_known_window():
# returns [0.01, 0.02, 0.03, 0.04], rf = 0; mean = 0.025;
# sample-var = 0.000166...; Sharpe = 0.025 / sqrt(var).
out = ta.SharpeRatio(4, 0.0).batch(np.array([0.01, 0.02, 0.03, 0.04]))
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_sortino_ratio_known_window():
# returns [-0.02, 0.01, -0.01, 0.03], mar = 0; mean = 0.0025;
# downside_sq = 0.0005; dd = sqrt(0.0005/4); Sortino = 0.0025/dd.
out = ta.SortinoRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
expected = 0.0025 / math.sqrt(0.000_125)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_max_drawdown_known_window():
# window [100, 120, 90] -> peak 120, trough 90 -> 25% drawdown.
out = ta.MaxDrawdown(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25, abs_tol=1e-12)
def test_pain_index_known_window():
# dd[0..2] = 0, 0, 0.25; mean = 0.25/3.
out = ta.PainIndex(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25 / 3.0, abs_tol=1e-12)
def test_profit_factor_known_window():
# gains 0.05, losses 0.03 -> PF = 5/3.
out = ta.ProfitFactor(4).batch(np.array([0.02, -0.01, 0.03, -0.02]))
assert math.isclose(out[3], 5.0 / 3.0, rel_tol=1e-9)
def test_gain_loss_ratio_known_window():
# avg_win 0.03, avg_loss 0.02 -> GLR = 1.5.
out = ta.GainLossRatio(4).batch(np.array([0.02, -0.01, 0.04, -0.03]))
assert math.isclose(out[3], 1.5, rel_tol=1e-9)
def test_omega_ratio_known_window():
# gains 0.04, losses 0.03 -> Omega = 4/3.
out = ta.OmegaRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
assert math.isclose(out[3], 4.0 / 3.0, rel_tol=1e-9)
def test_kelly_criterion_known_window():
# n_win=n_loss=2, payoff=2 -> Kelly = 0.5 - 0.5/2 = 0.25.
out = ta.KellyCriterion(4).batch(np.array([0.02, 0.04, -0.01, -0.02]))
assert math.isclose(out[3], 0.25, rel_tol=1e-9)
def test_drawdown_duration_under_water_counter():
out = ta.DrawdownDuration().batch(np.array([100.0, 95.0, 90.0, 85.0]))
np.testing.assert_allclose(out, [0.0, 1.0, 2.0, 3.0])
def test_recovery_factor_known_path():
# Start 100, peak 110, trough 88 -> max_dd = 0.20; end 130 ->
# net_return = 0.30 -> Recovery = 1.5.
prices = np.array([100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0])
out = ta.RecoveryFactor().batch(prices)
assert math.isclose(out[-1], 1.5, rel_tol=1e-9)
def test_alpha_perfect_capm_fit_yields_zero():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.0, abs_tol=1e-12)
def test_alpha_additive_offset_recovered():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = bench + 0.005
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.005, rel_tol=1e-9)
def test_treynor_ratio_known_window():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.TreynorRatio(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], bench.mean(), rel_tol=1e-9)
def test_information_ratio_known_window():
asset = np.array([0.02, 0.04, 0.06, 0.08])
bench = np.array([0.01, 0.02, 0.03, 0.04])
out = ta.InformationRatio(4).batch(asset, bench)
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
out = ta.ValueAtRisk(10, 0.95).batch(returns)
assert math.isclose(out[-1], 0.0455, rel_tol=1e-9)
def test_conditional_value_at_risk_known_window():
# tail = {-0.10}; CVaR = 0.10.
returns = np.array([i * 0.01 for i in range(-10, 10)])
out = ta.ConditionalValueAtRisk(20, 0.95).batch(returns)
assert math.isclose(out[-1], 0.10, rel_tol=1e-9)
def test_calmar_ratio_known_path():
# returns [0.10, -0.20, 0.05]; equity 1.0->1.10->0.88->0.924;
# mdd = 0.20; mean = -0.01666...; Calmar = mean / 0.20.
out = ta.CalmarRatio(3).batch(np.array([0.10, -0.20, 0.05]))
expected = ((0.10 - 0.20 + 0.05) / 3.0) / 0.20
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_average_drawdown_known_window():
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
# mean = (0.25 + 10/120) / 4.
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
expected = (0.25 + 10.0 / 120.0) / 4.0
assert math.isclose(out[-1], expected, rel_tol=1e-12)
def test_value_area_concentrated_volume_locates_poc():
# Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume
# at price 110. POC must fall inside the high-volume bar's [low, high]
+47 -2
View File
@@ -17,13 +17,17 @@ 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."""
"""Compare two float arrays treating NaN and matching-sign inf 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))
both_inf_same = np.isinf(a) & np.isinf(b) & (np.sign(a) == np.sign(b))
skip = both_nan | both_inf_same
with np.errstate(invalid="ignore"):
diff = np.abs(a - b)
return bool(np.all(np.where(skip, 0.0, diff) <= tol))
@pytest.fixture
@@ -106,6 +110,22 @@ SCALAR = [
(ta.MedianAbsoluteDeviation, (20,)),
(ta.Autocorrelation, (20, 1)),
(ta.HurstExponent, (40, 4)),
# Family 15 — Risk / Performance (scalar f64 input = period return or
# equity sample).
(ta.SharpeRatio, (20, 0.0)),
(ta.SortinoRatio, (20, 0.0)),
(ta.CalmarRatio, (20,)),
(ta.OmegaRatio, (20, 0.0)),
(ta.MaxDrawdown, (20,)),
(ta.AverageDrawdown, (20,)),
(ta.DrawdownDuration, ()),
(ta.PainIndex, (20,)),
(ta.ValueAtRisk, (20, 0.95)),
(ta.ConditionalValueAtRisk, (20, 0.95)),
(ta.ProfitFactor, (20,)),
(ta.GainLossRatio, (20,)),
(ta.RecoveryFactor, ()),
(ta.KellyCriterion, (20,)),
]
@@ -133,6 +153,31 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
]
@pytest.mark.parametrize("cls, args", PAIR, ids=[c.__name__ for c, _ in PAIR])
def test_pair_streaming_matches_batch(cls, args, sine_prices):
asset = np.ascontiguousarray(sine_prices.astype(np.float64))
bench = np.ascontiguousarray((sine_prices * 0.7 + 0.001).astype(np.float64))
batch = cls(*args).batch(asset, bench)
assert batch.shape == asset.shape
assert batch.dtype == np.float64
streamer = cls(*args)
streamed = []
for a, b in zip(asset, bench):
v = streamer.update(float(a), float(b))
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