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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user