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:
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
- **M2Measure** — M2 measure (Modigliani; Sharpe expressed in benchmark return units) (`M2Measure`).
|
||||||
|
- **UpsidePotentialRatio** — Upside Potential Ratio (upside mean over downside deviation) (`UpsidePotentialRatio`).
|
||||||
|
- **GainToPainRatio** — Gain-to-Pain Ratio (sum of returns over sum of losses) (`GainToPainRatio`).
|
||||||
|
- **CommonSenseRatio** — Common Sense Ratio (tail ratio times gain-to-pain) (`CommonSenseRatio`).
|
||||||
|
- **KRatio** — K-Ratio (Kestner; equity-curve slope over its standard error) (`KRatio`).
|
||||||
|
- **TailRatio** — Tail Ratio (95th over absolute 5th return percentile) (`TailRatio`).
|
||||||
|
- **MartinRatio** — Martin Ratio (Ulcer Performance Index; return over RMS drawdown) (`MartinRatio`).
|
||||||
|
- **BurkeRatio** — Burke Ratio (return over root-sum-squared drawdowns) (`BurkeRatio`).
|
||||||
|
- **SterlingRatio** — Sterling Ratio (mean return over average drawdown) (`SterlingRatio`).
|
||||||
|
|
||||||
## [0.7.2] - 2026-06-08
|
## [0.7.2] - 2026-06-08
|
||||||
- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
|
- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=498" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=507" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||||
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
|||||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||||
every one of the 498 indicators; start at the
|
every one of the 507 indicators; start at the
|
||||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||||
@@ -66,7 +66,7 @@ an afterthought — **live, tick-by-tick data** — without giving up the breadt
|
|||||||
a full batch library, and without making you reimplement your indicators four
|
a full batch library, and without making you reimplement your indicators four
|
||||||
times to get there.
|
times to get there.
|
||||||
|
|
||||||
- **The biggest streaming-native catalogue, period.** 498 indicators across 24
|
- **The biggest streaming-native catalogue, period.** 507 indicators across 24
|
||||||
families — candlesticks, harmonic & chart patterns, market profile, market
|
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||||
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||||
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||||
@@ -77,7 +77,7 @@ times to get there.
|
|||||||
- **Correct by construction, not by hope.** Every `update` validates its input,
|
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||||
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||||
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||||
for all 498 indicators**.
|
for all 507 indicators**.
|
||||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
||||||
faster than the only other incremental peer and **thousands of times** faster
|
faster than the only other incremental peer and **thousands of times** faster
|
||||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||||
@@ -95,7 +95,7 @@ Every other library forces one of those compromises. Wickra doesn't:
|
|||||||
|
|
||||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **498** | **yes** |
|
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **507** | **yes** |
|
||||||
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
||||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||||
| yata | clean | partial | Rust only | ~35 | yes |
|
| yata | clean | partial | Rust only | ~35 | yes |
|
||||||
@@ -128,7 +128,7 @@ Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
|||||||
|
|
||||||
## Indicators
|
## Indicators
|
||||||
|
|
||||||
498 streaming-first indicators across twenty-four families. Every one passes the
|
507 streaming-first indicators across twenty-four families. Every one passes the
|
||||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||||
@@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
|||||||
```
|
```
|
||||||
wickra/
|
wickra/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── wickra-core/ core engine + all 498 indicators
|
│ ├── wickra-core/ core engine + all 507 indicators
|
||||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||||
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||||
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
|
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ function num(v) {
|
|||||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||||
|
|
||||||
const scalarFactories = {
|
const scalarFactories = {
|
||||||
|
M2Measure: () => new wickra.M2Measure(20, 0.0, 0.02),
|
||||||
|
UpsidePotentialRatio: () => new wickra.UpsidePotentialRatio(20, 0.0),
|
||||||
|
GainToPainRatio: () => new wickra.GainToPainRatio(12),
|
||||||
|
CommonSenseRatio: () => new wickra.CommonSenseRatio(20),
|
||||||
|
KRatio: () => new wickra.KRatio(30),
|
||||||
|
TailRatio: () => new wickra.TailRatio(20),
|
||||||
|
MartinRatio: () => new wickra.MartinRatio(14),
|
||||||
|
BurkeRatio: () => new wickra.BurkeRatio(12),
|
||||||
|
SterlingRatio: () => new wickra.SterlingRatio(12),
|
||||||
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
||||||
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
||||||
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
||||||
|
|||||||
Vendored
+81
@@ -1176,6 +1176,87 @@ export declare class UNIVERSALOSC {
|
|||||||
isReady(): boolean
|
isReady(): boolean
|
||||||
warmupPeriod(): number
|
warmupPeriod(): number
|
||||||
}
|
}
|
||||||
|
export type SterlingRatioNode = SterlingRatio
|
||||||
|
export declare class SterlingRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type BurkeRatioNode = BurkeRatio
|
||||||
|
export declare class BurkeRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type MartinRatioNode = MartinRatio
|
||||||
|
export declare class MartinRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type TailRatioNode = TailRatio
|
||||||
|
export declare class TailRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type KRatioNode = KRatio
|
||||||
|
export declare class KRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type CommonSenseRatioNode = CommonSenseRatio
|
||||||
|
export declare class CommonSenseRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type GainToPainRatioNode = GainToPainRatio
|
||||||
|
export declare class GainToPainRatio {
|
||||||
|
constructor(period: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type UpsidePotentialRatioNode = UpsidePotentialRatio
|
||||||
|
export declare class UpsidePotentialRatio {
|
||||||
|
constructor(period: number, mar: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
|
export type M2MeasureNode = M2Measure
|
||||||
|
export declare class M2Measure {
|
||||||
|
constructor(period: number, riskFree: number, benchmarkStddev: number)
|
||||||
|
update(value: number): number | null
|
||||||
|
batch(prices: Array<number>): Array<number>
|
||||||
|
reset(): void
|
||||||
|
isReady(): boolean
|
||||||
|
warmupPeriod(): number
|
||||||
|
}
|
||||||
export type BandpassFilterNode = BANDPASS
|
export type BandpassFilterNode = BANDPASS
|
||||||
export declare class BANDPASS {
|
export declare class BANDPASS {
|
||||||
constructor(period: number, bandwidth: number)
|
constructor(period: number, bandwidth: number)
|
||||||
|
|||||||
+10
-1
File diff suppressed because one or more lines are too long
@@ -245,9 +245,91 @@ node_scalar_indicator!(
|
|||||||
"UNIVERSALOSC",
|
"UNIVERSALOSC",
|
||||||
wc::UniversalOscillator
|
wc::UniversalOscillator
|
||||||
);
|
);
|
||||||
|
node_scalar_indicator!(SterlingRatioNode, "SterlingRatio", wc::SterlingRatio);
|
||||||
|
node_scalar_indicator!(BurkeRatioNode, "BurkeRatio", wc::BurkeRatio);
|
||||||
|
node_scalar_indicator!(MartinRatioNode, "MartinRatio", wc::MartinRatio);
|
||||||
|
node_scalar_indicator!(TailRatioNode, "TailRatio", wc::TailRatio);
|
||||||
|
node_scalar_indicator!(KRatioNode, "KRatio", wc::KRatio);
|
||||||
|
node_scalar_indicator!(
|
||||||
|
CommonSenseRatioNode,
|
||||||
|
"CommonSenseRatio",
|
||||||
|
wc::CommonSenseRatio
|
||||||
|
);
|
||||||
|
node_scalar_indicator!(GainToPainRatioNode, "GainToPainRatio", wc::GainToPainRatio);
|
||||||
|
|
||||||
// Multi-arg Ehlers scalars: hand-written (node_scalar_indicator! is single-period).
|
// Multi-arg Ehlers scalars: hand-written (node_scalar_indicator! is single-period).
|
||||||
|
|
||||||
|
#[napi(js_name = "UpsidePotentialRatio")]
|
||||||
|
pub struct UpsidePotentialRatioNode {
|
||||||
|
inner: wc::UpsidePotentialRatio,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl UpsidePotentialRatioNode {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32, mar: f64) -> napi::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::UpsidePotentialRatio::new(period as usize, mar).map_err(map_err)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||||
|
self.inner.update(value)
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||||
|
flatten(self.inner.batch(&prices))
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.inner.reset();
|
||||||
|
}
|
||||||
|
#[napi(js_name = "isReady")]
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.inner.is_ready()
|
||||||
|
}
|
||||||
|
#[napi(js_name = "warmupPeriod")]
|
||||||
|
pub fn warmup_period(&self) -> u32 {
|
||||||
|
self.inner.warmup_period() as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi(js_name = "M2Measure")]
|
||||||
|
pub struct M2MeasureNode {
|
||||||
|
inner: wc::M2Measure,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl M2MeasureNode {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32, risk_free: f64, benchmark_stddev: f64) -> napi::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::M2Measure::new(period as usize, risk_free, benchmark_stddev)
|
||||||
|
.map_err(map_err)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||||
|
self.inner.update(value)
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||||
|
flatten(self.inner.batch(&prices))
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.inner.reset();
|
||||||
|
}
|
||||||
|
#[napi(js_name = "isReady")]
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.inner.is_ready()
|
||||||
|
}
|
||||||
|
#[napi(js_name = "warmupPeriod")]
|
||||||
|
pub fn warmup_period(&self) -> u32 {
|
||||||
|
self.inner.warmup_period() as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[napi(js_name = "BANDPASS")]
|
#[napi(js_name = "BANDPASS")]
|
||||||
pub struct BandpassFilterNode {
|
pub struct BandpassFilterNode {
|
||||||
inner: wc::BandpassFilter,
|
inner: wc::BandpassFilter,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from ._wickra import (
|
from ._wickra import (
|
||||||
__version__,
|
__version__,
|
||||||
|
M2Measure,
|
||||||
|
UpsidePotentialRatio,
|
||||||
|
GainToPainRatio,
|
||||||
|
CommonSenseRatio,
|
||||||
|
KRatio,
|
||||||
|
TailRatio,
|
||||||
|
MartinRatio,
|
||||||
|
BurkeRatio,
|
||||||
|
SterlingRatio,
|
||||||
AUTOCORRPGRAM,
|
AUTOCORRPGRAM,
|
||||||
EVENBETTERSINE,
|
EVENBETTERSINE,
|
||||||
BANDPASS,
|
BANDPASS,
|
||||||
@@ -552,6 +561,15 @@ from ._wickra import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"M2Measure",
|
||||||
|
"UpsidePotentialRatio",
|
||||||
|
"GainToPainRatio",
|
||||||
|
"CommonSenseRatio",
|
||||||
|
"KRatio",
|
||||||
|
"TailRatio",
|
||||||
|
"MartinRatio",
|
||||||
|
"BurkeRatio",
|
||||||
|
"SterlingRatio",
|
||||||
"AUTOCORRPGRAM",
|
"AUTOCORRPGRAM",
|
||||||
"EVENBETTERSINE",
|
"EVENBETTERSINE",
|
||||||
"BANDPASS",
|
"BANDPASS",
|
||||||
|
|||||||
@@ -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 ==============================
|
// ============================== Stochastic ==============================
|
||||||
|
|
||||||
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
|
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
|
||||||
@@ -21908,6 +22252,123 @@ impl PyTickIndex {
|
|||||||
|
|
||||||
// ============================== Family 15: Risk / Performance ==============================
|
// ============================== 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)]
|
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PySharpeRatio {
|
struct PySharpeRatio {
|
||||||
@@ -25881,5 +26342,14 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<PyFryPanBottom>()?;
|
m.add_class::<PyFryPanBottom>()?;
|
||||||
m.add_class::<PyDumplingTop>()?;
|
m.add_class::<PyDumplingTop>()?;
|
||||||
m.add_class::<PyNewPriceLines>()?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|||||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||||
|
|
||||||
SCALAR = [
|
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.AUTOCORRPGRAM, (10, 48)),
|
||||||
(ta.EVENBETTERSINE, (40, 10)),
|
(ta.EVENBETTERSINE, (40, 10)),
|
||||||
(ta.BANDPASS, (20, 0.3)),
|
(ta.BANDPASS, (20, 0.3)),
|
||||||
|
|||||||
@@ -12755,6 +12755,15 @@ wasm_scalar_indicator!(WasmUniversalOscillator, "UNIVERSALOSC", wc::UniversalOsc
|
|||||||
wasm_scalar_indicator!(WasmBandpassFilter, "BANDPASS", wc::BandpassFilter, period: usize, bandwidth: f64);
|
wasm_scalar_indicator!(WasmBandpassFilter, "BANDPASS", wc::BandpassFilter, period: usize, bandwidth: f64);
|
||||||
wasm_scalar_indicator!(WasmEvenBetterSinewave, "EVENBETTERSINE", wc::EvenBetterSinewave, hp_period: usize, ssf_length: usize);
|
wasm_scalar_indicator!(WasmEvenBetterSinewave, "EVENBETTERSINE", wc::EvenBetterSinewave, hp_period: usize, ssf_length: usize);
|
||||||
wasm_scalar_indicator!(WasmAutocorrelationPeriodogram, "AUTOCORRPGRAM", wc::AutocorrelationPeriodogram, min_period: usize, max_period: usize);
|
wasm_scalar_indicator!(WasmAutocorrelationPeriodogram, "AUTOCORRPGRAM", wc::AutocorrelationPeriodogram, min_period: usize, max_period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmSterlingRatio, "SterlingRatio", wc::SterlingRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmBurkeRatio, "BurkeRatio", wc::BurkeRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmMartinRatio, "MartinRatio", wc::MartinRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmTailRatio, "TailRatio", wc::TailRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmKRatio, "KRatio", wc::KRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmCommonSenseRatio, "CommonSenseRatio", wc::CommonSenseRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmGainToPainRatio, "GainToPainRatio", wc::GainToPainRatio, period: usize);
|
||||||
|
wasm_scalar_indicator!(WasmUpsidePotentialRatio, "UpsidePotentialRatio", wc::UpsidePotentialRatio, period: usize, mar: f64);
|
||||||
|
wasm_scalar_indicator!(WasmM2Measure, "M2Measure", wc::M2Measure, period: usize, risk_free: f64, benchmark_stddev: f64);
|
||||||
|
|
||||||
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
|
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! Burke Ratio — mean return over the square root of the summed squared drawdowns.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Burke Ratio over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||||
|
/// peak_t = max_{s<=t} equity_s
|
||||||
|
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||||
|
/// Burke = mean(returns) / sqrt( Σ dd_t² )
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Burke Ratio divides the average per-period return by the **Euclidean norm of
|
||||||
|
/// the drawdowns** — the square root of the *sum* of squared drawdowns. Squaring
|
||||||
|
/// penalises deep drawdowns far more than shallow ones, and summing (rather than
|
||||||
|
/// averaging) means the denominator grows with both the depth and the *number* of
|
||||||
|
/// drawdowns. This makes Burke the most outlier-sensitive of Wickra's three
|
||||||
|
/// drawdown ratios: where the [`SterlingRatio`](crate::SterlingRatio) averages raw
|
||||||
|
/// drawdowns and shrugs off a single crater, Burke makes that crater dominate.
|
||||||
|
/// The [`MartinRatio`](crate::MartinRatio) sits between them with a root-*mean*
|
||||||
|
/// square of percentage drawdowns. A window that never draws down has a zero
|
||||||
|
/// denominator and the indicator reports `0.0`.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||||
|
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||||
|
/// series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, BurkeRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = BurkeRatio::new(12).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..24 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BurkeRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BurkeRatio {
|
||||||
|
/// Construct a Burke Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "burke ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let length = self.window.len() as f64;
|
||||||
|
let mut sum_return = 0.0;
|
||||||
|
let mut sum_drawdown_sq = 0.0;
|
||||||
|
let mut equity = 1.0;
|
||||||
|
let mut peak: f64 = 1.0;
|
||||||
|
for ret in &self.window {
|
||||||
|
sum_return += *ret;
|
||||||
|
equity *= 1.0 + *ret;
|
||||||
|
peak = peak.max(equity);
|
||||||
|
let drawdown = (peak - equity) / peak;
|
||||||
|
sum_drawdown_sq += drawdown * drawdown;
|
||||||
|
}
|
||||||
|
let denom = sum_drawdown_sq.sqrt();
|
||||||
|
if denom > 0.0 {
|
||||||
|
(sum_return / length) / denom
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for BurkeRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"BurkeRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
BurkeRatio::new(1),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let br = BurkeRatio::new(12).unwrap();
|
||||||
|
assert_eq!(br.period(), 12);
|
||||||
|
assert_eq!(br.warmup_period(), 12);
|
||||||
|
assert_eq!(br.name(), "BurkeRatio");
|
||||||
|
assert!(!br.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.1, -0.1, 0.1]: dd = [0, 0.1, 0.01].
|
||||||
|
// Σ dd² = 0.01 + 0.0001 = 0.0101; denom = sqrt(0.0101).
|
||||||
|
// Burke = (0.1/3) / sqrt(0.0101).
|
||||||
|
let mut br = BurkeRatio::new(3).unwrap();
|
||||||
|
let out = br.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
let expected = (0.1_f64 / 3.0) / (0.0101_f64).sqrt();
|
||||||
|
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_drawdown_is_zero() {
|
||||||
|
let mut br = BurkeRatio::new(3).unwrap();
|
||||||
|
let last = br
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn losing_window_is_negative() {
|
||||||
|
let mut br = BurkeRatio::new(3).unwrap();
|
||||||
|
let last = br
|
||||||
|
.batch(&[-0.05, -0.02, -0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert!(last < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut br = BurkeRatio::new(3).unwrap();
|
||||||
|
assert_eq!(br.update(0.1), None);
|
||||||
|
assert_eq!(br.update(f64::NAN), None);
|
||||||
|
assert_eq!(br.update(-0.1), None);
|
||||||
|
assert!(br.update(0.1).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut br = BurkeRatio::new(3).unwrap();
|
||||||
|
br.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
assert!(br.is_ready());
|
||||||
|
br.reset();
|
||||||
|
assert!(!br.is_ready());
|
||||||
|
assert_eq!(br.update(0.1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||||
|
.collect();
|
||||||
|
let batch = BurkeRatio::new(12).unwrap().batch(&rets);
|
||||||
|
let mut streamer = BurkeRatio::new(12).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! Common Sense Ratio (Schwager / Carver) — profit factor multiplied by the tail ratio.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Common Sense Ratio over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// ProfitFactor = Σ gains / Σ |losses| over the window
|
||||||
|
/// TailRatio = P95(returns) / |P5(returns)| over the window
|
||||||
|
/// CSR = ProfitFactor · TailRatio
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Common Sense Ratio fuses two views of a return series into one number. The
|
||||||
|
/// [profit factor](crate::ProfitFactor) captures the *body* of the distribution —
|
||||||
|
/// how much you make per unit you lose on the average bar. The
|
||||||
|
/// [`TailRatio`](crate::TailRatio) captures the *extremes* — whether the largest
|
||||||
|
/// gains outweigh the largest losses. Multiplying them produces a ratio that is
|
||||||
|
/// only comfortably above `1.0` when a strategy wins on both fronts: a respectable
|
||||||
|
/// profit factor can still hide catastrophic left-tail risk, and a fat right tail
|
||||||
|
/// means little if the body bleeds. Above `1.0` the strategy is sound on a
|
||||||
|
/// common-sense basis; below `1.0` something — body or tail — is working against it.
|
||||||
|
///
|
||||||
|
/// Percentiles use linear interpolation over the sorted window. A window with no
|
||||||
|
/// losses (zero profit-factor denominator) or no left tail (zero P5) reports `0.0`
|
||||||
|
/// rather than dividing by zero.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` re-sorts the window
|
||||||
|
/// (O(period log period)), which is O(1) in the length of the overall series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, CommonSenseRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = CommonSenseRatio::new(20).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..40 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommonSenseRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonSenseRatio {
|
||||||
|
/// Construct a Common Sense Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
|
||||||
|
/// two observations).
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "common sense ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
let mut gains = 0.0;
|
||||||
|
let mut losses = 0.0;
|
||||||
|
for ret in &self.window {
|
||||||
|
gains += ret.max(0.0);
|
||||||
|
losses += (-ret).max(0.0);
|
||||||
|
}
|
||||||
|
if losses <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||||
|
sorted.sort_unstable_by(f64::total_cmp);
|
||||||
|
let lower_tail = percentile(&sorted, 5.0).abs();
|
||||||
|
if lower_tail <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let profit_factor = gains / losses;
|
||||||
|
let tail_ratio = percentile(&sorted, 95.0) / lower_tail;
|
||||||
|
profit_factor * tail_ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear-interpolation percentile of an ascending, non-empty slice.
|
||||||
|
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||||
|
let last_index = sorted.len() - 1;
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let rank = pct / 100.0 * last_index as f64;
|
||||||
|
let floor = rank.floor();
|
||||||
|
// `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
|
||||||
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||||
|
let lower = floor as usize;
|
||||||
|
if lower >= last_index {
|
||||||
|
return sorted[last_index];
|
||||||
|
}
|
||||||
|
let frac = rank - floor;
|
||||||
|
sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for CommonSenseRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"CommonSenseRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
CommonSenseRatio::new(1),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let csr = CommonSenseRatio::new(20).unwrap();
|
||||||
|
assert_eq!(csr.period(), 20);
|
||||||
|
assert_eq!(csr.warmup_period(), 20);
|
||||||
|
assert_eq!(csr.name(), "CommonSenseRatio");
|
||||||
|
assert!(!csr.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// window [-0.04, -0.02, 0.0, 0.02, 0.04].
|
||||||
|
// gains = 0.06, losses = 0.06 -> profit factor 1.0.
|
||||||
|
// P95 = 0.036, |P5| = 0.036 -> tail ratio 1.0. CSR = 1.0.
|
||||||
|
let mut csr = CommonSenseRatio::new(5).unwrap();
|
||||||
|
let out = csr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
|
||||||
|
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_losses_is_zero() {
|
||||||
|
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||||
|
let last = csr
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_window_is_zero() {
|
||||||
|
// All zeros: no losses denominator -> zero (the gains/losses guard fires).
|
||||||
|
let mut csr = CommonSenseRatio::new(4).unwrap();
|
||||||
|
let last = csr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||||
|
assert_eq!(csr.update(0.01), None);
|
||||||
|
assert_eq!(csr.update(f64::NAN), None);
|
||||||
|
assert_eq!(csr.update(-0.02), None);
|
||||||
|
assert!(csr.update(0.03).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||||
|
csr.batch(&[-0.01, 0.0, 0.02]);
|
||||||
|
assert!(csr.is_ready());
|
||||||
|
csr.reset();
|
||||||
|
assert!(!csr.is_ready());
|
||||||
|
assert_eq!(csr.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||||
|
.collect();
|
||||||
|
let batch = CommonSenseRatio::new(15).unwrap().batch(&rets);
|
||||||
|
let mut streamer = CommonSenseRatio::new(15).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentile_at_top_returns_last() {
|
||||||
|
// The rank floor reaching the final index returns the largest element.
|
||||||
|
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_lower_tail_is_zero() {
|
||||||
|
// One loss but a 5th percentile of exactly zero: the tail term collapses
|
||||||
|
// and the indicator reports 0.0 rather than dividing by zero. With period
|
||||||
|
// 21 the 5% rank lands on sorted index 1, which is 0.0 here.
|
||||||
|
let mut returns = vec![0.0; 21];
|
||||||
|
returns[0] = -0.1;
|
||||||
|
let mut csr = CommonSenseRatio::new(21).unwrap();
|
||||||
|
let last = csr.batch(&returns).into_iter().flatten().last().unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Gain-to-Pain Ratio (Schwager) — sum of returns over the sum of losses.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Gain-to-Pain Ratio — Jack Schwager's measure of return per unit of downside:
|
||||||
|
/// the sum of all returns divided by the sum of the absolute *negative* returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GPR = Σ returns / Σ |negative returns| over the window
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Where the [`GainLossRatio`](crate::GainLossRatio) compares *average* win to
|
||||||
|
/// *average* loss and the [`ProfitFactor`](crate::ProfitFactor) compares gross
|
||||||
|
/// profit to gross loss, the Gain-to-Pain Ratio puts the **net** result over the
|
||||||
|
/// total pain endured to earn it. Schwager treats a GPR above `1.0` as good and
|
||||||
|
/// above `2.0` as excellent for a monthly return series: the strategy made more
|
||||||
|
/// than it lost on the way, and twice as much when GPR is `2`. A flat series, or
|
||||||
|
/// one with no losses, has no measurable pain and reports `0` (undefined).
|
||||||
|
///
|
||||||
|
/// The output is unbounded and may be negative (a net-losing window). The first
|
||||||
|
/// value lands after `period` returns; each `update` is O(1).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, GainToPainRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = GainToPainRatio::new(12).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..24 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.02);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GainToPainRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
sum_all: f64,
|
||||||
|
sum_pain: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GainToPainRatio {
|
||||||
|
/// Construct a Gain-to-Pain Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(Error::PeriodZero);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
sum_all: 0.0,
|
||||||
|
sum_pain: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for GainToPainRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return if self.window.len() == self.period {
|
||||||
|
Some(self.compute())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
let old = self.window.pop_front().expect("non-empty");
|
||||||
|
self.sum_all -= old;
|
||||||
|
if old < 0.0 {
|
||||||
|
self.sum_pain -= -old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
self.sum_all += ret;
|
||||||
|
if ret < 0.0 {
|
||||||
|
self.sum_pain += -ret;
|
||||||
|
}
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
self.sum_all = 0.0;
|
||||||
|
self.sum_pain = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"GainToPainRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GainToPainRatio {
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
if self.sum_pain > 0.0 {
|
||||||
|
self.sum_all / self.sum_pain
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_period() {
|
||||||
|
assert!(matches!(GainToPainRatio::new(0), Err(Error::PeriodZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let g = GainToPainRatio::new(12).unwrap();
|
||||||
|
assert_eq!(g.period(), 12);
|
||||||
|
assert_eq!(g.warmup_period(), 12);
|
||||||
|
assert_eq!(g.name(), "GainToPainRatio");
|
||||||
|
assert!(!g.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_emission_at_warmup_period() {
|
||||||
|
let mut g = GainToPainRatio::new(4).unwrap();
|
||||||
|
let out = g.batch(&[0.01, -0.01, 0.02, -0.01, 0.03]);
|
||||||
|
for v in out.iter().take(3) {
|
||||||
|
assert!(v.is_none());
|
||||||
|
}
|
||||||
|
assert!(out[3].is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns: +0.04, -0.02 -> sum_all = 0.02, pain = 0.02 -> GPR = 1.0.
|
||||||
|
let mut g = GainToPainRatio::new(2).unwrap();
|
||||||
|
let out = g.batch(&[0.04, -0.02]);
|
||||||
|
assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn net_losing_window_is_negative() {
|
||||||
|
let mut g = GainToPainRatio::new(3).unwrap();
|
||||||
|
let last = g
|
||||||
|
.batch(&[-0.03, 0.01, -0.02])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert!(last < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pain_is_zero() {
|
||||||
|
let mut g = GainToPainRatio::new(3).unwrap();
|
||||||
|
let last = g
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite() {
|
||||||
|
let mut g = GainToPainRatio::new(2).unwrap();
|
||||||
|
let ready = g
|
||||||
|
.batch(&[0.04, -0.02])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(g.update(f64::NAN), Some(ready));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_finite_before_ready_is_none() {
|
||||||
|
// A non-finite value arriving before the window fills yields None.
|
||||||
|
let mut g = GainToPainRatio::new(3).unwrap();
|
||||||
|
assert_eq!(g.update(0.02), None);
|
||||||
|
assert_eq!(g.update(f64::NAN), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut g = GainToPainRatio::new(2).unwrap();
|
||||||
|
g.batch(&[0.04, -0.02]);
|
||||||
|
assert!(g.is_ready());
|
||||||
|
g.reset();
|
||||||
|
assert!(!g.is_ready());
|
||||||
|
assert_eq!(g.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin() * 0.02).collect();
|
||||||
|
let batch = GainToPainRatio::new(12).unwrap().batch(&rets);
|
||||||
|
let mut b = GainToPainRatio::new(12).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| b.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! K-Ratio (Kestner) — slope of the cumulative-return curve over the standard error of that slope.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// K-Ratio over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// Lars Kestner's K-Ratio measures the *consistency* of an equity curve, not just
|
||||||
|
/// its return. It builds the cumulative-return curve over the window, fits an
|
||||||
|
/// ordinary-least-squares trend line through it against time, and divides the
|
||||||
|
/// fitted slope by the standard error of that slope:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// equity_t = Σ_{i<=t} return_i (cumulative curve, t = 1..period)
|
||||||
|
/// slope, intercept = OLS(equity_t ~ t)
|
||||||
|
/// SE(slope) = sqrt( (Σ residual² / (period − 2)) / Σ(t − t̄)² )
|
||||||
|
/// K-Ratio = slope / SE(slope)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A high K-Ratio means the equity curve climbs *steadily* — a steep slope with
|
||||||
|
/// little scatter around the trend. A strategy that earns the same total return in
|
||||||
|
/// a few lucky jumps scores lower because its residual scatter inflates the
|
||||||
|
/// standard error. This is the original 1996 form; later Kestner revisions scale by
|
||||||
|
/// the number of periods (`slope / (SE · period)` in 2003, `slope / (SE · √period)`
|
||||||
|
/// in 2013) — apply that scaling downstream if you need to compare across window
|
||||||
|
/// lengths.
|
||||||
|
///
|
||||||
|
/// A perfectly straight window (e.g. constant returns) has zero residual scatter,
|
||||||
|
/// so the slope's standard error is zero and the K-Ratio is undefined; the
|
||||||
|
/// indicator reports `0.0` in that degenerate case. The statistic therefore needs
|
||||||
|
/// some dispersion in the returns to be meaningful.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` re-fits the line
|
||||||
|
/// over the window (O(period)), which is O(1) in the length of the overall series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, KRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = KRatio::new(30).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..60 {
|
||||||
|
/// last = indicator.update(0.001 + (f64::from(i) * 0.3).sin() * 0.01);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct KRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KRatio {
|
||||||
|
/// Construct a K-Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 3` (the slope's standard error
|
||||||
|
/// divides by `period − 2`).
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 3 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "k-ratio needs period >= 3",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
let count = self.window.len();
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let length = count as f64;
|
||||||
|
// Build the cumulative-equity curve and its mean.
|
||||||
|
let mut equity = 0.0;
|
||||||
|
let mut curve: Vec<f64> = Vec::with_capacity(count);
|
||||||
|
let mut sum_equity = 0.0;
|
||||||
|
for ret in &self.window {
|
||||||
|
equity += *ret;
|
||||||
|
curve.push(equity);
|
||||||
|
sum_equity += equity;
|
||||||
|
}
|
||||||
|
// Times are 1..=count, so Σt = count(count+1)/2 in closed form.
|
||||||
|
let mean_time = f64::midpoint(length, 1.0);
|
||||||
|
let mean_equity = sum_equity / length;
|
||||||
|
let mut sxx = 0.0;
|
||||||
|
let mut sxy = 0.0;
|
||||||
|
for (index, value) in curve.iter().enumerate() {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let time = (index + 1) as f64;
|
||||||
|
let dt = time - mean_time;
|
||||||
|
sxx += dt * dt;
|
||||||
|
sxy += dt * (value - mean_equity);
|
||||||
|
}
|
||||||
|
// sxx > 0 for count >= 2 (distinct integer times), guaranteed by period >= 3.
|
||||||
|
let slope = sxy / sxx;
|
||||||
|
let intercept = mean_equity - slope * mean_time;
|
||||||
|
let mut sse = 0.0;
|
||||||
|
for (index, value) in curve.iter().enumerate() {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let time = (index + 1) as f64;
|
||||||
|
let residual = value - (intercept + slope * time);
|
||||||
|
sse += residual * residual;
|
||||||
|
}
|
||||||
|
if sse <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let se_slope = (sse / (length - 2.0) / sxx).sqrt();
|
||||||
|
slope / se_slope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for KRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"KRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_three() {
|
||||||
|
assert!(matches!(KRatio::new(2), Err(Error::InvalidPeriod { .. })));
|
||||||
|
assert!(matches!(KRatio::new(0), Err(Error::InvalidPeriod { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let kr = KRatio::new(30).unwrap();
|
||||||
|
assert_eq!(kr.period(), 30);
|
||||||
|
assert_eq!(kr.warmup_period(), 30);
|
||||||
|
assert_eq!(kr.name(), "KRatio");
|
||||||
|
assert!(!kr.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.01, 0.02, 0.03] -> equity curve [0.01, 0.03, 0.06].
|
||||||
|
// slope = 0.025, SE(slope) = sqrt((1/60000)/1/2) = 1/sqrt(120000).
|
||||||
|
// K-Ratio = 0.025 * sqrt(120000) = 5*sqrt(3) ≈ 8.660254.
|
||||||
|
let mut kr = KRatio::new(3).unwrap();
|
||||||
|
let out = kr.batch(&[0.01, 0.02, 0.03]);
|
||||||
|
let expected = 0.025_f64 / (1.0_f64 / 120_000.0).sqrt();
|
||||||
|
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_returns_are_degenerate_zero() {
|
||||||
|
// A perfectly linear equity curve has zero residual scatter -> undefined.
|
||||||
|
let mut kr = KRatio::new(4).unwrap();
|
||||||
|
let last = kr.batch(&[0.01; 4]).into_iter().flatten().last().unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rising_curve_is_positive() {
|
||||||
|
let mut kr = KRatio::new(5).unwrap();
|
||||||
|
let last = kr
|
||||||
|
.batch(&[0.01, 0.012, 0.009, 0.011, 0.013])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert!(last > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut kr = KRatio::new(3).unwrap();
|
||||||
|
assert_eq!(kr.update(0.01), None);
|
||||||
|
assert_eq!(kr.update(f64::NAN), None);
|
||||||
|
assert_eq!(kr.update(0.02), None);
|
||||||
|
assert!(kr.update(0.03).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut kr = KRatio::new(3).unwrap();
|
||||||
|
kr.batch(&[0.01, 0.02, 0.03]);
|
||||||
|
assert!(kr.is_ready());
|
||||||
|
kr.reset();
|
||||||
|
assert!(!kr.is_ready());
|
||||||
|
assert_eq!(kr.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.01)
|
||||||
|
.collect();
|
||||||
|
let batch = KRatio::new(20).unwrap().batch(&rets);
|
||||||
|
let mut streamer = KRatio::new(20).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! M² / Modigliani–Modigliani measure — Sharpe expressed in benchmark return units.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// M² (Modigliani–Modigliani) measure over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Sharpe = (mean(returns) − risk_free) / stddev(returns)
|
||||||
|
/// M² = risk_free + Sharpe · benchmark_stddev
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The [`SharpeRatio`](crate::SharpeRatio) is dimensionless, which makes it hard to
|
||||||
|
/// communicate: "0.8" means little to a client. M² rescales the Sharpe ratio back
|
||||||
|
/// into *return units* by levering (or de-levering) the portfolio to the
|
||||||
|
/// benchmark's volatility. The result answers a concrete question: "if this
|
||||||
|
/// strategy had run at the market's risk level, what return would it have
|
||||||
|
/// produced?" Two portfolios can then be ranked on the same risk-adjusted scale,
|
||||||
|
/// and M² preserves the Sharpe ordering while being quoted as a percentage.
|
||||||
|
///
|
||||||
|
/// `stddev` is the sample standard deviation (Bessel's `n − 1`).
|
||||||
|
/// `risk_free` is the per-period risk-free rate and `benchmark_stddev` the
|
||||||
|
/// per-period volatility of the benchmark, both supplied by the caller at the
|
||||||
|
/// return frequency. A flat window has zero volatility and the Sharpe ratio is
|
||||||
|
/// undefined; the indicator returns `0.0` in that case rather than producing `NaN`.
|
||||||
|
///
|
||||||
|
/// Each `update` is O(1) — running sums maintain `Σr` and `Σr²` as the window slides.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, M2Measure};
|
||||||
|
///
|
||||||
|
/// let mut indicator = M2Measure::new(20, 0.0, 0.02).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..40 {
|
||||||
|
/// last = indicator.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct M2Measure {
|
||||||
|
period: usize,
|
||||||
|
risk_free: f64,
|
||||||
|
benchmark_stddev: f64,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
sum: f64,
|
||||||
|
sum_sq: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl M2Measure {
|
||||||
|
/// Construct an M² measure over `period` returns with the given per-period
|
||||||
|
/// risk-free rate and benchmark standard deviation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
|
||||||
|
/// [`Error::InvalidParameter`] if `risk_free` is not finite or
|
||||||
|
/// `benchmark_stddev` is negative or not finite.
|
||||||
|
pub fn new(period: usize, risk_free: f64, benchmark_stddev: f64) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "m2 measure needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !risk_free.is_finite() || !benchmark_stddev.is_finite() || benchmark_stddev < 0.0 {
|
||||||
|
return Err(Error::InvalidParameter {
|
||||||
|
message: "risk_free must be finite and benchmark_stddev finite and non-negative",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
risk_free,
|
||||||
|
benchmark_stddev,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
sum: 0.0,
|
||||||
|
sum_sq: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured per-period risk-free rate.
|
||||||
|
pub const fn risk_free(&self) -> f64 {
|
||||||
|
self.risk_free
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured per-period benchmark standard deviation.
|
||||||
|
pub const fn benchmark_stddev(&self) -> f64 {
|
||||||
|
self.benchmark_stddev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for M2Measure {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
let old = self.window.pop_front().expect("non-empty");
|
||||||
|
self.sum -= old;
|
||||||
|
self.sum_sq -= old * old;
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
self.sum += ret;
|
||||||
|
self.sum_sq += ret * ret;
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = self.period as f64;
|
||||||
|
let mean = self.sum / n;
|
||||||
|
let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
|
||||||
|
let sd = var.sqrt();
|
||||||
|
if sd == 0.0 {
|
||||||
|
return Some(0.0);
|
||||||
|
}
|
||||||
|
let sharpe = (mean - self.risk_free) / sd;
|
||||||
|
Some(self.risk_free + sharpe * self.benchmark_stddev)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
self.sum = 0.0;
|
||||||
|
self.sum_sq = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"M2Measure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
M2Measure::new(1, 0.0, 0.02),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_benchmark_stddev() {
|
||||||
|
assert!(matches!(
|
||||||
|
M2Measure::new(10, 0.0, -0.01),
|
||||||
|
Err(Error::InvalidParameter { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
M2Measure::new(10, f64::NAN, 0.02),
|
||||||
|
Err(Error::InvalidParameter { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let m2 = M2Measure::new(20, 0.001, 0.02).unwrap();
|
||||||
|
assert_eq!(m2.period(), 20);
|
||||||
|
assert_relative_eq!(m2.risk_free(), 0.001, epsilon = 1e-12);
|
||||||
|
assert_relative_eq!(m2.benchmark_stddev(), 0.02, epsilon = 1e-12);
|
||||||
|
assert_eq!(m2.warmup_period(), 20);
|
||||||
|
assert_eq!(m2.name(), "M2Measure");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.01, 0.02, 0.03, 0.04], rf = 0, benchmark_stddev = 0.02.
|
||||||
|
// mean = 0.025, sd = sqrt(0.000166666...), Sharpe = 0.025 / sd.
|
||||||
|
// M2 = 0 + Sharpe * 0.02.
|
||||||
|
let mut m2 = M2Measure::new(4, 0.0, 0.02).unwrap();
|
||||||
|
let out = m2.batch(&[0.01, 0.02, 0.03, 0.04]);
|
||||||
|
let sharpe = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
|
||||||
|
assert_relative_eq!(out[3].unwrap(), sharpe * 0.02, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_returns_yield_zero() {
|
||||||
|
let mut m2 = M2Measure::new(5, 0.0, 0.02).unwrap();
|
||||||
|
for v in m2.batch(&[0.01; 10]).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
|
||||||
|
assert_eq!(m2.update(0.01), None);
|
||||||
|
assert_eq!(m2.update(f64::NAN), None);
|
||||||
|
assert_eq!(m2.update(0.02), None);
|
||||||
|
assert!(m2.update(0.03).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
|
||||||
|
m2.batch(&[0.01, 0.02, 0.03]);
|
||||||
|
assert!(m2.is_ready());
|
||||||
|
m2.reset();
|
||||||
|
assert!(!m2.is_ready());
|
||||||
|
assert_eq!(m2.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..50)
|
||||||
|
.map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
|
||||||
|
.collect();
|
||||||
|
let batch = M2Measure::new(10, 0.0, 0.02).unwrap().batch(&rets);
|
||||||
|
let mut streamer = M2Measure::new(10, 0.0, 0.02).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Martin Ratio (Ulcer Performance Index) — mean return over the Ulcer Index.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Martin Ratio — also called the Ulcer Performance Index (UPI) — over a trailing
|
||||||
|
/// window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||||
|
/// peak_t = max_{s<=t} equity_s
|
||||||
|
/// dd_t% = 100 · (peak_t − equity_t) / peak_t (percentage drawdown)
|
||||||
|
/// UlcerIdx = sqrt( mean( dd_t%² ) )
|
||||||
|
/// Martin = mean(returns) / UlcerIdx
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Martin Ratio divides the average per-period return by the **Ulcer Index** —
|
||||||
|
/// the root-mean-square of the *percentage* drawdowns. The Ulcer Index, by
|
||||||
|
/// construction, measures the depth *and* duration of the time spent under water:
|
||||||
|
/// a long shallow slump and a short deep one can score the same. Compared to
|
||||||
|
/// Wickra's other drawdown ratios, Martin uses the RMS (not the average as in the
|
||||||
|
/// [`SterlingRatio`](crate::SterlingRatio), nor the un-normalised sum-norm as in the
|
||||||
|
/// [`BurkeRatio`](crate::BurkeRatio)) and expresses drawdowns in **percent**, so its
|
||||||
|
/// denominator is on a `0..100` scale and its output is numerically smaller than
|
||||||
|
/// the fractional-drawdown ratios. A window that never draws down has an Ulcer Index
|
||||||
|
/// of zero and the indicator reports `0.0`.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||||
|
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||||
|
/// series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, MartinRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = MartinRatio::new(14).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..28 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MartinRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MartinRatio {
|
||||||
|
/// Construct a Martin Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "martin ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let length = self.window.len() as f64;
|
||||||
|
let mut sum_return = 0.0;
|
||||||
|
let mut sum_drawdown_pct_sq = 0.0;
|
||||||
|
let mut equity = 1.0;
|
||||||
|
let mut peak: f64 = 1.0;
|
||||||
|
for ret in &self.window {
|
||||||
|
sum_return += *ret;
|
||||||
|
equity *= 1.0 + *ret;
|
||||||
|
peak = peak.max(equity);
|
||||||
|
let drawdown_pct = 100.0 * (peak - equity) / peak;
|
||||||
|
sum_drawdown_pct_sq += drawdown_pct * drawdown_pct;
|
||||||
|
}
|
||||||
|
let ulcer_index = (sum_drawdown_pct_sq / length).sqrt();
|
||||||
|
if ulcer_index > 0.0 {
|
||||||
|
(sum_return / length) / ulcer_index
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for MartinRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MartinRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
MartinRatio::new(1),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mr = MartinRatio::new(14).unwrap();
|
||||||
|
assert_eq!(mr.period(), 14);
|
||||||
|
assert_eq!(mr.warmup_period(), 14);
|
||||||
|
assert_eq!(mr.name(), "MartinRatio");
|
||||||
|
assert!(!mr.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.1, -0.1, 0.1]: drawdowns% = [0, 10, 1].
|
||||||
|
// Ulcer Index = sqrt((0 + 100 + 1)/3) = sqrt(101/3).
|
||||||
|
// Martin = (0.1/3) / sqrt(101/3).
|
||||||
|
let mut mr = MartinRatio::new(3).unwrap();
|
||||||
|
let out = mr.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
let expected = (0.1_f64 / 3.0) / (101.0_f64 / 3.0).sqrt();
|
||||||
|
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_drawdown_is_zero() {
|
||||||
|
let mut mr = MartinRatio::new(3).unwrap();
|
||||||
|
let last = mr
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn losing_window_is_negative() {
|
||||||
|
let mut mr = MartinRatio::new(3).unwrap();
|
||||||
|
let last = mr
|
||||||
|
.batch(&[-0.05, -0.02, -0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert!(last < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut mr = MartinRatio::new(3).unwrap();
|
||||||
|
assert_eq!(mr.update(0.1), None);
|
||||||
|
assert_eq!(mr.update(f64::NAN), None);
|
||||||
|
assert_eq!(mr.update(-0.1), None);
|
||||||
|
assert!(mr.update(0.1).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut mr = MartinRatio::new(3).unwrap();
|
||||||
|
mr.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
assert!(mr.is_ready());
|
||||||
|
mr.reset();
|
||||||
|
assert!(!mr.is_ready());
|
||||||
|
assert_eq!(mr.update(0.1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||||
|
.collect();
|
||||||
|
let batch = MartinRatio::new(14).unwrap().batch(&rets);
|
||||||
|
let mut streamer = MartinRatio::new(14).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ mod bomar_bands;
|
|||||||
mod breadth_thrust;
|
mod breadth_thrust;
|
||||||
mod breakaway;
|
mod breakaway;
|
||||||
mod bullish_percent_index;
|
mod bullish_percent_index;
|
||||||
|
mod burke_ratio;
|
||||||
mod butterfly;
|
mod butterfly;
|
||||||
mod calendar_spread;
|
mod calendar_spread;
|
||||||
mod calmar_ratio;
|
mod calmar_ratio;
|
||||||
@@ -84,6 +85,7 @@ mod cmf;
|
|||||||
mod cmo;
|
mod cmo;
|
||||||
mod coefficient_of_variation;
|
mod coefficient_of_variation;
|
||||||
mod cointegration;
|
mod cointegration;
|
||||||
|
mod common_sense_ratio;
|
||||||
mod composite_profile;
|
mod composite_profile;
|
||||||
mod concealing_baby_swallow;
|
mod concealing_baby_swallow;
|
||||||
mod conditional_value_at_risk;
|
mod conditional_value_at_risk;
|
||||||
@@ -163,6 +165,7 @@ mod funding_rate;
|
|||||||
mod funding_rate_mean;
|
mod funding_rate_mean;
|
||||||
mod funding_rate_zscore;
|
mod funding_rate_zscore;
|
||||||
mod gain_loss_ratio;
|
mod gain_loss_ratio;
|
||||||
|
mod gain_to_pain_ratio;
|
||||||
mod gap_side_by_side_white;
|
mod gap_side_by_side_white;
|
||||||
mod garch11;
|
mod garch11;
|
||||||
mod garman_klass;
|
mod garman_klass;
|
||||||
@@ -214,6 +217,7 @@ mod inverted_hammer;
|
|||||||
mod jarque_bera;
|
mod jarque_bera;
|
||||||
mod jma;
|
mod jma;
|
||||||
mod jump_indicator;
|
mod jump_indicator;
|
||||||
|
mod k_ratio;
|
||||||
mod kagi_bars;
|
mod kagi_bars;
|
||||||
mod kalman_hedge_ratio;
|
mod kalman_hedge_ratio;
|
||||||
mod kama;
|
mod kama;
|
||||||
@@ -241,6 +245,7 @@ mod log_return;
|
|||||||
mod long_legged_doji;
|
mod long_legged_doji;
|
||||||
mod long_line;
|
mod long_line;
|
||||||
mod long_short_ratio;
|
mod long_short_ratio;
|
||||||
|
mod m2_measure;
|
||||||
mod ma_envelope;
|
mod ma_envelope;
|
||||||
mod macd;
|
mod macd;
|
||||||
mod macd_ext;
|
mod macd_ext;
|
||||||
@@ -248,6 +253,7 @@ mod macd_fix;
|
|||||||
mod macd_histogram;
|
mod macd_histogram;
|
||||||
mod mama;
|
mod mama;
|
||||||
mod market_facilitation_index;
|
mod market_facilitation_index;
|
||||||
|
mod martin_ratio;
|
||||||
mod marubozu;
|
mod marubozu;
|
||||||
mod mass_index;
|
mod mass_index;
|
||||||
mod mat_hold;
|
mod mat_hold;
|
||||||
@@ -389,6 +395,7 @@ mod starc_bands;
|
|||||||
mod stc;
|
mod stc;
|
||||||
mod std_dev;
|
mod std_dev;
|
||||||
mod step_trailing_stop;
|
mod step_trailing_stop;
|
||||||
|
mod sterling_ratio;
|
||||||
mod stick_sandwich;
|
mod stick_sandwich;
|
||||||
mod stoch_rsi;
|
mod stoch_rsi;
|
||||||
mod stochastic;
|
mod stochastic;
|
||||||
@@ -396,6 +403,7 @@ mod stochastic_cci;
|
|||||||
mod super_smoother;
|
mod super_smoother;
|
||||||
mod super_trend;
|
mod super_trend;
|
||||||
mod t3;
|
mod t3;
|
||||||
|
mod tail_ratio;
|
||||||
mod taker_buy_sell_ratio;
|
mod taker_buy_sell_ratio;
|
||||||
mod takuri;
|
mod takuri;
|
||||||
mod tasuki_gap;
|
mod tasuki_gap;
|
||||||
@@ -466,6 +474,7 @@ mod universal_oscillator;
|
|||||||
mod up_down_volume_ratio;
|
mod up_down_volume_ratio;
|
||||||
mod upside_gap_three_methods;
|
mod upside_gap_three_methods;
|
||||||
mod upside_gap_two_crows;
|
mod upside_gap_two_crows;
|
||||||
|
mod upside_potential_ratio;
|
||||||
mod value_area;
|
mod value_area;
|
||||||
mod value_at_risk;
|
mod value_at_risk;
|
||||||
mod variance;
|
mod variance;
|
||||||
@@ -561,6 +570,7 @@ pub use bomar_bands::{BomarBands, BomarBandsOutput};
|
|||||||
pub use breadth_thrust::BreadthThrust;
|
pub use breadth_thrust::BreadthThrust;
|
||||||
pub use breakaway::Breakaway;
|
pub use breakaway::Breakaway;
|
||||||
pub use bullish_percent_index::BullishPercentIndex;
|
pub use bullish_percent_index::BullishPercentIndex;
|
||||||
|
pub use burke_ratio::BurkeRatio;
|
||||||
pub use butterfly::Butterfly;
|
pub use butterfly::Butterfly;
|
||||||
pub use calendar_spread::CalendarSpread;
|
pub use calendar_spread::CalendarSpread;
|
||||||
pub use calmar_ratio::CalmarRatio;
|
pub use calmar_ratio::CalmarRatio;
|
||||||
@@ -582,6 +592,7 @@ pub use cmf::ChaikinMoneyFlow;
|
|||||||
pub use cmo::Cmo;
|
pub use cmo::Cmo;
|
||||||
pub use coefficient_of_variation::CoefficientOfVariation;
|
pub use coefficient_of_variation::CoefficientOfVariation;
|
||||||
pub use cointegration::{Cointegration, CointegrationOutput};
|
pub use cointegration::{Cointegration, CointegrationOutput};
|
||||||
|
pub use common_sense_ratio::CommonSenseRatio;
|
||||||
pub use composite_profile::{CompositeProfile, CompositeProfileOutput};
|
pub use composite_profile::{CompositeProfile, CompositeProfileOutput};
|
||||||
pub use concealing_baby_swallow::ConcealingBabySwallow;
|
pub use concealing_baby_swallow::ConcealingBabySwallow;
|
||||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||||
@@ -661,6 +672,7 @@ pub use funding_rate::FundingRate;
|
|||||||
pub use funding_rate_mean::FundingRateMean;
|
pub use funding_rate_mean::FundingRateMean;
|
||||||
pub use funding_rate_zscore::FundingRateZScore;
|
pub use funding_rate_zscore::FundingRateZScore;
|
||||||
pub use gain_loss_ratio::GainLossRatio;
|
pub use gain_loss_ratio::GainLossRatio;
|
||||||
|
pub use gain_to_pain_ratio::GainToPainRatio;
|
||||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||||
pub use garch11::Garch11;
|
pub use garch11::Garch11;
|
||||||
pub use garman_klass::GarmanKlassVolatility;
|
pub use garman_klass::GarmanKlassVolatility;
|
||||||
@@ -712,6 +724,7 @@ pub use inverted_hammer::InvertedHammer;
|
|||||||
pub use jarque_bera::JarqueBera;
|
pub use jarque_bera::JarqueBera;
|
||||||
pub use jma::Jma;
|
pub use jma::Jma;
|
||||||
pub use jump_indicator::JumpIndicator;
|
pub use jump_indicator::JumpIndicator;
|
||||||
|
pub use k_ratio::KRatio;
|
||||||
pub use kagi_bars::{KagiBar, KagiBars};
|
pub use kagi_bars::{KagiBar, KagiBars};
|
||||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||||
pub use kama::Kama;
|
pub use kama::Kama;
|
||||||
@@ -739,6 +752,7 @@ pub use log_return::LogReturn;
|
|||||||
pub use long_legged_doji::LongLeggedDoji;
|
pub use long_legged_doji::LongLeggedDoji;
|
||||||
pub use long_line::LongLine;
|
pub use long_line::LongLine;
|
||||||
pub use long_short_ratio::LongShortRatio;
|
pub use long_short_ratio::LongShortRatio;
|
||||||
|
pub use m2_measure::M2Measure;
|
||||||
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
||||||
pub use macd::{MacdIndicator, MacdOutput};
|
pub use macd::{MacdIndicator, MacdOutput};
|
||||||
pub use macd_ext::{MaType, MacdExt};
|
pub use macd_ext::{MaType, MacdExt};
|
||||||
@@ -746,6 +760,7 @@ pub use macd_fix::MacdFix;
|
|||||||
pub use macd_histogram::MacdHistogram;
|
pub use macd_histogram::MacdHistogram;
|
||||||
pub use mama::{Mama, MamaOutput};
|
pub use mama::{Mama, MamaOutput};
|
||||||
pub use market_facilitation_index::MarketFacilitationIndex;
|
pub use market_facilitation_index::MarketFacilitationIndex;
|
||||||
|
pub use martin_ratio::MartinRatio;
|
||||||
pub use marubozu::Marubozu;
|
pub use marubozu::Marubozu;
|
||||||
pub use mass_index::MassIndex;
|
pub use mass_index::MassIndex;
|
||||||
pub use mat_hold::MatHold;
|
pub use mat_hold::MatHold;
|
||||||
@@ -887,6 +902,7 @@ pub use starc_bands::{StarcBands, StarcBandsOutput};
|
|||||||
pub use stc::Stc;
|
pub use stc::Stc;
|
||||||
pub use std_dev::StdDev;
|
pub use std_dev::StdDev;
|
||||||
pub use step_trailing_stop::StepTrailingStop;
|
pub use step_trailing_stop::StepTrailingStop;
|
||||||
|
pub use sterling_ratio::SterlingRatio;
|
||||||
pub use stick_sandwich::StickSandwich;
|
pub use stick_sandwich::StickSandwich;
|
||||||
pub use stoch_rsi::StochRsi;
|
pub use stoch_rsi::StochRsi;
|
||||||
pub use stochastic::{Stochastic, StochasticOutput};
|
pub use stochastic::{Stochastic, StochasticOutput};
|
||||||
@@ -894,6 +910,7 @@ pub use stochastic_cci::StochasticCci;
|
|||||||
pub use super_smoother::SuperSmoother;
|
pub use super_smoother::SuperSmoother;
|
||||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||||
pub use t3::T3;
|
pub use t3::T3;
|
||||||
|
pub use tail_ratio::TailRatio;
|
||||||
pub use taker_buy_sell_ratio::TakerBuySellRatio;
|
pub use taker_buy_sell_ratio::TakerBuySellRatio;
|
||||||
pub use takuri::Takuri;
|
pub use takuri::Takuri;
|
||||||
pub use tasuki_gap::TasukiGap;
|
pub use tasuki_gap::TasukiGap;
|
||||||
@@ -964,6 +981,7 @@ pub use universal_oscillator::UniversalOscillator;
|
|||||||
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
||||||
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
||||||
pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
||||||
|
pub use upside_potential_ratio::UpsidePotentialRatio;
|
||||||
pub use value_area::{ValueArea, ValueAreaOutput};
|
pub use value_area::{ValueArea, ValueAreaOutput};
|
||||||
pub use value_at_risk::ValueAtRisk;
|
pub use value_at_risk::ValueAtRisk;
|
||||||
pub use variance::Variance;
|
pub use variance::Variance;
|
||||||
@@ -1542,6 +1560,15 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
|||||||
"Alpha",
|
"Alpha",
|
||||||
"WinRate",
|
"WinRate",
|
||||||
"Expectancy",
|
"Expectancy",
|
||||||
|
"SterlingRatio",
|
||||||
|
"BurkeRatio",
|
||||||
|
"MartinRatio",
|
||||||
|
"TailRatio",
|
||||||
|
"KRatio",
|
||||||
|
"CommonSenseRatio",
|
||||||
|
"GainToPainRatio",
|
||||||
|
"UpsidePotentialRatio",
|
||||||
|
"M2Measure",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1654,6 +1681,6 @@ mod family_tests {
|
|||||||
// the actual indicator count is the early-warning signal that an
|
// the actual indicator count is the early-warning signal that an
|
||||||
// indicator was added without being assigned a family.
|
// indicator was added without being assigned a family.
|
||||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||||
assert_eq!(total, 498, "FAMILIES total drifted from indicator count");
|
assert_eq!(total, 507, "FAMILIES total drifted from indicator count");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! Sterling Ratio — mean return over the average drawdown of the equity curve.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Sterling Ratio over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||||
|
/// peak_t = max_{s<=t} equity_s
|
||||||
|
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||||
|
/// Sterling = mean(returns) / mean(dd_t)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Sterling Ratio rewards return per unit of *typical* pain: it divides the
|
||||||
|
/// average per-period return by the **average drawdown** experienced along the
|
||||||
|
/// compounded equity curve. Of the three drawdown-based ratios Wickra ships it is
|
||||||
|
/// the gentlest on outliers — averaging the drawdowns means one deep crater does
|
||||||
|
/// not dominate the way it does in the [`BurkeRatio`](crate::BurkeRatio) (which
|
||||||
|
/// sums squared drawdowns) or the [`MartinRatio`](crate::MartinRatio) (which uses
|
||||||
|
/// the root-mean-square percentage drawdown). A window that never draws down has
|
||||||
|
/// zero average drawdown and the indicator reports `0.0`.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||||
|
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||||
|
/// series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, SterlingRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = SterlingRatio::new(12).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..24 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SterlingRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SterlingRatio {
|
||||||
|
/// Construct a Sterling Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "sterling ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let length = self.window.len() as f64;
|
||||||
|
let mut sum_return = 0.0;
|
||||||
|
let mut sum_drawdown = 0.0;
|
||||||
|
let mut equity = 1.0;
|
||||||
|
let mut peak: f64 = 1.0;
|
||||||
|
for ret in &self.window {
|
||||||
|
sum_return += *ret;
|
||||||
|
equity *= 1.0 + *ret;
|
||||||
|
peak = peak.max(equity);
|
||||||
|
sum_drawdown += (peak - equity) / peak;
|
||||||
|
}
|
||||||
|
let avg_drawdown = sum_drawdown / length;
|
||||||
|
if avg_drawdown > 0.0 {
|
||||||
|
(sum_return / length) / avg_drawdown
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for SterlingRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SterlingRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
SterlingRatio::new(1),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let sr = SterlingRatio::new(12).unwrap();
|
||||||
|
assert_eq!(sr.period(), 12);
|
||||||
|
assert_eq!(sr.warmup_period(), 12);
|
||||||
|
assert_eq!(sr.name(), "SterlingRatio");
|
||||||
|
assert!(!sr.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.1, -0.1, 0.1]:
|
||||||
|
// equity 1.1, 0.99, 1.089; peak stays 1.1.
|
||||||
|
// dd = [0, 0.1, 0.01]; avg_dd = 0.11/3; mean_return = 0.1/3.
|
||||||
|
// Sterling = (0.1/3) / (0.11/3) = 0.1/0.11.
|
||||||
|
let mut sr = SterlingRatio::new(3).unwrap();
|
||||||
|
let out = sr.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
assert_relative_eq!(out[2].unwrap(), 0.1_f64 / 0.11, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_drawdown_is_zero() {
|
||||||
|
// Monotonically rising equity never draws down.
|
||||||
|
let mut sr = SterlingRatio::new(3).unwrap();
|
||||||
|
let last = sr
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn losing_window_is_negative() {
|
||||||
|
let mut sr = SterlingRatio::new(3).unwrap();
|
||||||
|
let last = sr
|
||||||
|
.batch(&[-0.05, -0.02, -0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert!(last < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut sr = SterlingRatio::new(3).unwrap();
|
||||||
|
assert_eq!(sr.update(0.1), None);
|
||||||
|
assert_eq!(sr.update(f64::NAN), None);
|
||||||
|
assert_eq!(sr.update(-0.1), None);
|
||||||
|
assert!(sr.update(0.1).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut sr = SterlingRatio::new(3).unwrap();
|
||||||
|
sr.batch(&[0.1, -0.1, 0.1]);
|
||||||
|
assert!(sr.is_ready());
|
||||||
|
sr.reset();
|
||||||
|
assert!(!sr.is_ready());
|
||||||
|
assert_eq!(sr.update(0.1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||||
|
.collect();
|
||||||
|
let batch = SterlingRatio::new(12).unwrap().batch(&rets);
|
||||||
|
let mut streamer = SterlingRatio::new(12).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Tail Ratio — the right tail (95th percentile) over the absolute left tail (5th percentile).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Tail Ratio over a trailing window of `period` returns.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// TailRatio = P95(returns) / |P5(returns)|
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Tail Ratio contrasts the magnitude of the best outcomes against the worst:
|
||||||
|
/// the 95th percentile of the return distribution divided by the absolute value of
|
||||||
|
/// the 5th percentile. A value above `1.0` means the right tail (upside surprises)
|
||||||
|
/// is fatter than the left tail (downside surprises); below `1.0` means crashes are
|
||||||
|
/// larger than rallies. It is a distribution-shape statistic, distinct from the
|
||||||
|
/// average-based [`SharpeRatio`](crate::SharpeRatio): two series with the same mean
|
||||||
|
/// and variance can have very different tail ratios.
|
||||||
|
///
|
||||||
|
/// Percentiles are computed by linear interpolation over the sorted window
|
||||||
|
/// (the same rule `NumPy` uses by default). A window whose 5th percentile is exactly
|
||||||
|
/// zero has no measurable left tail and the indicator reports `0.0` rather than
|
||||||
|
/// dividing by zero.
|
||||||
|
///
|
||||||
|
/// The first value lands after `period` returns; each `update` re-sorts the window
|
||||||
|
/// (O(period log period)), which is O(1) in the length of the overall series.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, TailRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = TailRatio::new(20).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..40 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TailRatio {
|
||||||
|
period: usize,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TailRatio {
|
||||||
|
/// Construct a Tail Ratio over `period` returns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
|
||||||
|
/// two observations to interpolate).
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "tail ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute(&self) -> f64 {
|
||||||
|
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||||
|
sorted.sort_unstable_by(f64::total_cmp);
|
||||||
|
let upper = percentile(&sorted, 95.0);
|
||||||
|
let lower = percentile(&sorted, 5.0).abs();
|
||||||
|
if lower > 0.0 {
|
||||||
|
upper / lower
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear-interpolation percentile of an ascending, non-empty slice.
|
||||||
|
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||||
|
let last_index = sorted.len() - 1;
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let rank = pct / 100.0 * last_index as f64;
|
||||||
|
let floor = rank.floor();
|
||||||
|
// `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
|
||||||
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||||
|
let lower = floor as usize;
|
||||||
|
if lower >= last_index {
|
||||||
|
return sorted[last_index];
|
||||||
|
}
|
||||||
|
let frac = rank - floor;
|
||||||
|
sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for TailRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.compute())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"TailRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
TailRatio::new(1),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
TailRatio::new(0),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let tr = TailRatio::new(20).unwrap();
|
||||||
|
assert_eq!(tr.period(), 20);
|
||||||
|
assert_eq!(tr.warmup_period(), 20);
|
||||||
|
assert_eq!(tr.name(), "TailRatio");
|
||||||
|
assert!(!tr.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// sorted window [-0.04, -0.02, 0.0, 0.02, 0.04], last_index = 4.
|
||||||
|
// P95: rank 3.8 -> 0.02 + 0.8*(0.04-0.02) = 0.036.
|
||||||
|
// P5: rank 0.2 -> -0.04 + 0.2*(0.02) = -0.036, abs 0.036.
|
||||||
|
// ratio = 0.036 / 0.036 = 1.0.
|
||||||
|
let mut tr = TailRatio::new(5).unwrap();
|
||||||
|
let out = tr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
|
||||||
|
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fatter_right_tail_exceeds_one() {
|
||||||
|
let mut tr = TailRatio::new(5).unwrap();
|
||||||
|
let out = tr.batch(&[-0.01, 0.0, 0.01, 0.02, 0.10]);
|
||||||
|
assert!(out[4].unwrap() > 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_window_is_zero() {
|
||||||
|
let mut tr = TailRatio::new(4).unwrap();
|
||||||
|
let last = tr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut tr = TailRatio::new(3).unwrap();
|
||||||
|
assert_eq!(tr.update(0.01), None);
|
||||||
|
assert_eq!(tr.update(f64::NAN), None);
|
||||||
|
assert_eq!(tr.update(0.02), None);
|
||||||
|
assert!(tr.update(0.03).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut tr = TailRatio::new(3).unwrap();
|
||||||
|
tr.batch(&[-0.01, 0.0, 0.02]);
|
||||||
|
assert!(tr.is_ready());
|
||||||
|
tr.reset();
|
||||||
|
assert!(!tr.is_ready());
|
||||||
|
assert_eq!(tr.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||||
|
.collect();
|
||||||
|
let batch = TailRatio::new(15).unwrap().batch(&rets);
|
||||||
|
let mut streamer = TailRatio::new(15).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentile_at_top_returns_last() {
|
||||||
|
// When the rank floor reaches the final index (the 100th percentile), the
|
||||||
|
// helper returns the largest element without interpolating past the end.
|
||||||
|
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! Upside Potential Ratio (Sortino, van der Meer & Plantinga) — upside mean over downside deviation.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Upside Potential Ratio over a trailing window of `period` returns, measured
|
||||||
|
/// relative to a minimal acceptable return (`mar`).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// upside = mean( max(r − mar, 0) ) over the window
|
||||||
|
/// downside = sqrt( mean( min(r − mar, 0)² ) ) over the window
|
||||||
|
/// UPR = upside / downside
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Where the [`SharpeRatio`](crate::SharpeRatio) divides excess return by *total*
|
||||||
|
/// volatility (penalising upside and downside symmetrically), the Upside Potential
|
||||||
|
/// Ratio rewards only the average outperformance above the threshold while
|
||||||
|
/// penalising solely the downside deviation below it. It is the purest expression
|
||||||
|
/// of the Sortino philosophy: investors do not dislike upside variance, only
|
||||||
|
/// shortfall risk.
|
||||||
|
///
|
||||||
|
/// `mar` (minimal acceptable return) is the per-period hurdle the caller supplies
|
||||||
|
/// (e.g. `0.0` for break-even, or a target rate matching the return frequency). A
|
||||||
|
/// window that never breaches the threshold has zero downside deviation; the
|
||||||
|
/// indicator then reports `0.0` rather than dividing by zero.
|
||||||
|
///
|
||||||
|
/// Each `update` is O(1) — running sums maintain the upside total and the
|
||||||
|
/// downside sum-of-squares as the window slides.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Indicator, UpsidePotentialRatio};
|
||||||
|
///
|
||||||
|
/// let mut indicator = UpsidePotentialRatio::new(20, 0.0).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..40 {
|
||||||
|
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UpsidePotentialRatio {
|
||||||
|
period: usize,
|
||||||
|
mar: f64,
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
sum_upside: f64,
|
||||||
|
sum_downside_sq: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpsidePotentialRatio {
|
||||||
|
/// Construct an Upside Potential Ratio over `period` returns with minimal
|
||||||
|
/// acceptable return `mar`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
|
||||||
|
/// [`Error::InvalidParameter`] if `mar` is not finite.
|
||||||
|
pub fn new(period: usize, mar: f64) -> Result<Self> {
|
||||||
|
if period < 2 {
|
||||||
|
return Err(Error::InvalidPeriod {
|
||||||
|
message: "upside potential ratio needs period >= 2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !mar.is_finite() {
|
||||||
|
return Err(Error::InvalidParameter {
|
||||||
|
message: "mar must be finite",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
mar,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
sum_upside: 0.0,
|
||||||
|
sum_downside_sq: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured window of returns.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured minimal acceptable return.
|
||||||
|
pub const fn mar(&self) -> f64 {
|
||||||
|
self.mar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for UpsidePotentialRatio {
|
||||||
|
type Input = f64;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||||
|
if !ret.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
let old = self.window.pop_front().expect("non-empty");
|
||||||
|
let excess = old - self.mar;
|
||||||
|
self.sum_upside -= excess.max(0.0);
|
||||||
|
self.sum_downside_sq -= excess.min(0.0).powi(2);
|
||||||
|
}
|
||||||
|
let excess = ret - self.mar;
|
||||||
|
self.sum_upside += excess.max(0.0);
|
||||||
|
self.sum_downside_sq += excess.min(0.0).powi(2);
|
||||||
|
self.window.push_back(ret);
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = self.period as f64;
|
||||||
|
let upside_mean = self.sum_upside / n;
|
||||||
|
let downside_dev = (self.sum_downside_sq / n).sqrt();
|
||||||
|
if downside_dev > 0.0 {
|
||||||
|
Some(upside_mean / downside_dev)
|
||||||
|
} else {
|
||||||
|
Some(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
self.sum_upside = 0.0;
|
||||||
|
self.sum_downside_sq = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.len() == self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"UpsidePotentialRatio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_period_less_than_two() {
|
||||||
|
assert!(matches!(
|
||||||
|
UpsidePotentialRatio::new(1, 0.0),
|
||||||
|
Err(Error::InvalidPeriod { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_finite_mar() {
|
||||||
|
assert!(matches!(
|
||||||
|
UpsidePotentialRatio::new(10, f64::NAN),
|
||||||
|
Err(Error::InvalidParameter { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
|
||||||
|
assert_eq!(upr.period(), 20);
|
||||||
|
assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
|
||||||
|
assert_eq!(upr.warmup_period(), 20);
|
||||||
|
assert_eq!(upr.name(), "UpsidePotentialRatio");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_value() {
|
||||||
|
// returns [0.02, -0.01, 0.03, -0.02], mar = 0.
|
||||||
|
// upside = (0.02 + 0 + 0.03 + 0)/4 = 0.0125.
|
||||||
|
// downside = sqrt((0 + 0.0001 + 0 + 0.0004)/4) = sqrt(0.000125).
|
||||||
|
// UPR = 0.0125 / sqrt(0.000125).
|
||||||
|
let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
|
||||||
|
let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
|
||||||
|
let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
|
||||||
|
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_downside_is_zero() {
|
||||||
|
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
|
||||||
|
let last = upr
|
||||||
|
.batch(&[0.01, 0.02, 0.03])
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_finite_input() {
|
||||||
|
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
|
||||||
|
assert_eq!(upr.update(0.01), None);
|
||||||
|
assert_eq!(upr.update(f64::INFINITY), None);
|
||||||
|
assert_eq!(upr.update(-0.02), None);
|
||||||
|
assert!(upr.update(0.03).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
|
||||||
|
upr.batch(&[0.02, -0.01]);
|
||||||
|
assert!(upr.is_ready());
|
||||||
|
upr.reset();
|
||||||
|
assert!(!upr.is_ready());
|
||||||
|
assert_eq!(upr.update(0.01), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let rets: Vec<f64> = (0..60)
|
||||||
|
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||||
|
.collect();
|
||||||
|
let batch = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
|
||||||
|
let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
|
||||||
|
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,30 +66,31 @@ pub use indicators::{
|
|||||||
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
|
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
|
||||||
BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation,
|
BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation,
|
||||||
BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput,
|
BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput,
|
||||||
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
|
BreadthThrust, Breakaway, BullishPercentIndex, BurkeRatio, Butterfly, CalendarSpread,
|
||||||
Camarilla, CamarillaPivotsOutput, CandleVolume, CandleVolumeOutput, Cci, CenterOfGravity,
|
CalmarRatio, Camarilla, CamarillaPivotsOutput, CandleVolume, CandleVolumeOutput, Cci,
|
||||||
CentralPivotRange, CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
|
CenterOfGravity, CentralPivotRange, CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow,
|
||||||
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
|
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
|
||||||
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
|
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
|
||||||
CompositeProfile, CompositeProfileOutput, ConcealingBabySwallow, ConditionalValueAtRisk,
|
CommonSenseRatio, CompositeProfile, CompositeProfileOutput, ConcealingBabySwallow,
|
||||||
ConnorsRsi, Coppock, CorrelationTrendIndicator, Counterattack, Crab, CumulativeVolumeDelta,
|
ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, Counterattack, Crab,
|
||||||
CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
|
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||||
DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
|
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||||
DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev, DisparityIndex,
|
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||||
DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput,
|
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||||
DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo,
|
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||||
DragonflyDoji, DrawdownDuration, DumplingTop, Dx, DynamicMomentumIndex, EaseOfMovement,
|
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, DumplingTop, Dx,
|
||||||
EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone,
|
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||||
ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition, Engulfing, Equivolume, EquivolumeOutput,
|
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
|
||||||
EstimatedLeverageRatio, EvenBetterSinewave, EveningDojiStar, Evwma, EwmaVolatility, Expectancy,
|
Engulfing, Equivolume, EquivolumeOutput, EstimatedLeverageRatio, EvenBetterSinewave,
|
||||||
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
|
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
|
||||||
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
|
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
|
||||||
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
|
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
|
||||||
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
|
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
|
||||||
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom,
|
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
|
||||||
FundingBasis, FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore,
|
FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom, FundingBasis,
|
||||||
GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
|
FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio,
|
||||||
|
GainToPainRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
|
||||||
GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross,
|
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross,
|
||||||
HasbrouckInformationShare, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator,
|
HasbrouckInformationShare, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator,
|
||||||
@@ -100,22 +101,22 @@ pub use indicators::{
|
|||||||
Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio,
|
Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio,
|
||||||
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayIntensity,
|
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayIntensity,
|
||||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||||
InverseFisherTransform, InvertedHammer, JarqueBera, Jma, JumpIndicator, KagiBars,
|
InverseFisherTransform, InvertedHammer, JarqueBera, Jma, JumpIndicator, KRatio, KagiBars,
|
||||||
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
||||||
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
||||||
KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
|
KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
|
||||||
KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
|
KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
|
||||||
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope,
|
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope,
|
||||||
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji,
|
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji,
|
||||||
LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram,
|
LongLine, LongShortRatio, M2Measure, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix,
|
||||||
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
|
MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
|
||||||
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
MartinRatio, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator,
|
||||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
|
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel,
|
||||||
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
|
MedianChannelOutput, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
|
||||||
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, MurreyMathLines,
|
MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar,
|
||||||
MurreyMathLinesOutput, NakedPoc, Natr, NewHighsNewLows, NewPriceLines, Nrtr, NrtrOutput, Nvi,
|
MurreyMathLines, MurreyMathLinesOutput, NakedPoc, Natr, NewHighsNewLows, NewPriceLines, Nrtr,
|
||||||
OIPriceDivergence, OIWeighted, Obv, OiToVolumeRatio, OmegaRatio, OnNeck, OpenInterestDelta,
|
NrtrOutput, Nvi, OIPriceDivergence, OIWeighted, Obv, OiToVolumeRatio, OmegaRatio, OnNeck,
|
||||||
OpenInterestMomentum, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
OpenInterestDelta, OpenInterestMomentum, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
||||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
||||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||||
@@ -135,11 +136,11 @@ pub use indicators::{
|
|||||||
SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
|
SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
|
||||||
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
|
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
|
||||||
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
|
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
|
||||||
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput,
|
StepTrailingStop, SterlingRatio, StickSandwich, StochRsi, Stochastic, StochasticCci,
|
||||||
SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap,
|
StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TailRatio, TakerBuySellRatio,
|
||||||
TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential,
|
Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker,
|
||||||
TdLines, TdLinesOutput, TdMovingAverage, TdMovingAverageOutput, TdOpen, TdPressure,
|
TdDifferential, TdLines, TdLinesOutput, TdMovingAverage, TdMovingAverageOutput, TdOpen,
|
||||||
TdPropulsion, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
TdPressure, TdPropulsion, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
||||||
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis,
|
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis,
|
||||||
ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows,
|
ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows,
|
||||||
ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile,
|
ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile,
|
||||||
@@ -148,16 +149,16 @@ pub use indicators::{
|
|||||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf,
|
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf,
|
||||||
TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer,
|
TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer,
|
||||||
TwiggsMoneyFlow, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
|
TwiggsMoneyFlow, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
|
||||||
UniversalOscillator, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
UniversalOscillator, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows,
|
||||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
UpsidePotentialRatio, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
|
||||||
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
|
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
|
||||||
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
|
||||||
VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput,
|
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
|
||||||
VolumeWeightedSr, VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
VolumeWeightedMacdOutput, VolumeWeightedSr, VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin,
|
||||||
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
|
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend,
|
||||||
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
|
WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput,
|
||||||
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit,
|
||||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||||
};
|
};
|
||||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||||
// line so the indicator-count tooling (which scans the braced block above and
|
// line so the indicator-count tooling (which scans the braced block above and
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ That includes:
|
|||||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||||
[Node](https://docs.wickra.org/Quickstart-Node), and
|
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||||
- A per-indicator deep dive for every one of the **498 indicators** across
|
- A per-indicator deep dive for every one of the **507 indicators** across
|
||||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
|
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
|
||||||
|
|
||||||
use libfuzzer_sys::fuzz_target;
|
use libfuzzer_sys::fuzz_target;
|
||||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, BurkeRatio, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, CommonSenseRatio, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GainToPainRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, KRatio, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, M2Measure, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MartinRatio, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, SterlingRatio, StochRsi, SuperSmoother, TailRatio, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, UpsidePotentialRatio, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||||
|
|
||||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||||
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
||||||
@@ -211,6 +211,15 @@ fuzz_target!(|data: Vec<f64>| {
|
|||||||
drive(|| KellyCriterion::new(20).unwrap(), &data);
|
drive(|| KellyCriterion::new(20).unwrap(), &data);
|
||||||
drive(|| WinRate::new(20).unwrap(), &data);
|
drive(|| WinRate::new(20).unwrap(), &data);
|
||||||
drive(|| Expectancy::new(20).unwrap(), &data);
|
drive(|| Expectancy::new(20).unwrap(), &data);
|
||||||
|
drive(|| SterlingRatio::new(12).unwrap(), &data);
|
||||||
|
drive(|| BurkeRatio::new(12).unwrap(), &data);
|
||||||
|
drive(|| MartinRatio::new(14).unwrap(), &data);
|
||||||
|
drive(|| TailRatio::new(20).unwrap(), &data);
|
||||||
|
drive(|| KRatio::new(30).unwrap(), &data);
|
||||||
|
drive(|| CommonSenseRatio::new(20).unwrap(), &data);
|
||||||
|
drive(|| GainToPainRatio::new(12).unwrap(), &data);
|
||||||
|
drive(|| UpsidePotentialRatio::new(20, 0.0).unwrap(), &data);
|
||||||
|
drive(|| M2Measure::new(20, 0.0, 0.02).unwrap(), &data);
|
||||||
|
|
||||||
// RecoveryFactor and DrawdownDuration produce non-`f64` outputs / have
|
// RecoveryFactor and DrawdownDuration produce non-`f64` outputs / have
|
||||||
// no `period` knob, so they cannot use the `drive` helper directly.
|
// no `period` knob, so they cannot use the `drive` helper directly.
|
||||||
|
|||||||
Reference in New Issue
Block a user