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

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

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

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

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

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

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

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

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

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

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

* test(family-15): cover cold paths flagged by codecov patch
This commit is contained in:
kingchenc
2026-05-26 20:44:21 +02:00
committed by GitHub
parent 55284a3042
commit 4e3c41ea80
34 changed files with 5727 additions and 73 deletions
+13
View File
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
pragmatically as standard `Indicator`s rather than a separate
`wickra-metrics` crate; the input is a scalar `f64` per bar (period return,
equity sample, or trade P&L depending on the metric).
- **Scalar `Indicator<f64>` — 14 metrics:** Sharpe Ratio, Sortino Ratio,
Calmar Ratio, Omega Ratio, Max Drawdown (rolling), Average Drawdown,
Drawdown Duration (time-under-water), Pain Index, Value at Risk
(historical, linear-interpolated percentile), Conditional Value at Risk
(Expected Shortfall), Profit Factor, Gain/Loss Ratio, Recovery Factor,
Kelly Criterion.
- **Two-series `Indicator<(f64, f64)>` — 3 metrics on `(asset_return,
benchmark_return)` pairs:** Treynor Ratio, Information Ratio,
Jensen's Alpha (CAPM).
- **Candlestick patterns family (15 indicators).** A new "Candlestick
Patterns" family covers the standard 1- to 3-bar reversal and
continuation shapes: `Doji`, `Hammer`, `InvertedHammer`, `HangingMan`,
+3 -2
View File
@@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries
## Indicators
196 streaming-first indicators across fifteen families. Every one passes the
213 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
@@ -130,6 +130,7 @@ semantics tests.
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -202,7 +203,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 196 indicators
│ ├── wickra-core/ core engine + all 213 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -17,6 +17,7 @@ const open = close.map((c) => c - 0.5);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
return Math.abs(a - b) < 1e-9;
}
@@ -102,8 +103,46 @@ const scalarFactories = {
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
HurstExponent: () => new wickra.HurstExponent(40, 4),
// Family 15 — Risk / Performance metrics (scalar f64 input).
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
CalmarRatio: () => new wickra.CalmarRatio(20),
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
MaxDrawdown: () => new wickra.MaxDrawdown(20),
AverageDrawdown: () => new wickra.AverageDrawdown(20),
DrawdownDuration: () => new wickra.DrawdownDuration(),
PainIndex: () => new wickra.PainIndex(20),
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
ProfitFactor: () => new wickra.ProfitFactor(20),
GainLossRatio: () => new wickra.GainLossRatio(20),
RecoveryFactor: () => new wickra.RecoveryFactor(),
KellyCriterion: () => new wickra.KellyCriterion(20),
};
// --- Two-series (asset, benchmark) ratio indicators ---
const ratioPairFactories = {
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
InformationRatio: () => new wickra.InformationRatio(20),
Alpha: () => new wickra.Alpha(20, 0),
};
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
for (const [name, make] of Object.entries(ratioPairFactories)) {
test(`${name}: streaming update matches batch (pair)`, () => {
const batch = make().batch(asset, bench);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(asset[i], bench[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
for (const [name, make] of Object.entries(scalarFactories)) {
test(`${name}: streaming update matches batch`, () => {
const batch = make().batch(close);
+19 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -510,3 +510,21 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
// Family 15: Risk / Performance metrics
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
module.exports.OmegaRatio = OmegaRatio
module.exports.MaxDrawdown = MaxDrawdown
module.exports.AverageDrawdown = AverageDrawdown
module.exports.DrawdownDuration = DrawdownDuration
module.exports.PainIndex = PainIndex
module.exports.ValueAtRisk = ValueAtRisk
module.exports.ConditionalValueAtRisk = ConditionalValueAtRisk
module.exports.ProfitFactor = ProfitFactor
module.exports.GainLossRatio = GainLossRatio
module.exports.RecoveryFactor = RecoveryFactor
module.exports.KellyCriterion = KellyCriterion
module.exports.TreynorRatio = TreynorRatio
module.exports.InformationRatio = InformationRatio
module.exports.Alpha = Alpha
+663
View File
@@ -8419,3 +8419,666 @@ node_candle_pattern!(TweezerNode, wc::Tweezer, "Tweezer");
node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop");
node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside");
node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside");
// ============================== Family 15: Risk / Performance ==============================
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
// is written by hand rather than going through the `node_scalar_indicator!`
// macro above.
#[napi(js_name = "SharpeRatio")]
pub struct SharpeRatioNode {
inner: wc::SharpeRatio,
}
#[napi]
impl SharpeRatioNode {
#[napi(constructor)]
pub fn new(period: u32, risk_free: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::SharpeRatio::new(period as usize, risk_free).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 = "SortinoRatio")]
pub struct SortinoRatioNode {
inner: wc::SortinoRatio,
}
#[napi]
impl SortinoRatioNode {
#[napi(constructor)]
pub fn new(period: u32, mar: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::SortinoRatio::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 = "CalmarRatio")]
pub struct CalmarRatioNode {
inner: wc::CalmarRatio,
}
#[napi]
impl CalmarRatioNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::CalmarRatio::new(period as usize).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 = "OmegaRatio")]
pub struct OmegaRatioNode {
inner: wc::OmegaRatio,
}
#[napi]
impl OmegaRatioNode {
#[napi(constructor)]
pub fn new(period: u32, threshold: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::OmegaRatio::new(period as usize, threshold).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 = "MaxDrawdown")]
pub struct MaxDrawdownNode {
inner: wc::MaxDrawdown,
}
#[napi]
impl MaxDrawdownNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MaxDrawdown::new(period as usize).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 = "AverageDrawdown")]
pub struct AverageDrawdownNode {
inner: wc::AverageDrawdown,
}
#[napi]
impl AverageDrawdownNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AverageDrawdown::new(period as usize).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 = "DrawdownDuration")]
pub struct DrawdownDurationNode {
inner: wc::DrawdownDuration,
}
impl Default for DrawdownDurationNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl DrawdownDurationNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::DrawdownDuration::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<u32> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
prices
.iter()
.map(|p| self.inner.update(*p).map_or(f64::NAN, f64::from))
.collect()
}
#[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 = "PainIndex")]
pub struct PainIndexNode {
inner: wc::PainIndex,
}
#[napi]
impl PainIndexNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PainIndex::new(period as usize).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 = "ValueAtRisk")]
pub struct ValueAtRiskNode {
inner: wc::ValueAtRisk,
}
#[napi]
impl ValueAtRiskNode {
#[napi(constructor)]
pub fn new(period: u32, confidence: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::ValueAtRisk::new(period as usize, confidence).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 = "ConditionalValueAtRisk")]
pub struct ConditionalValueAtRiskNode {
inner: wc::ConditionalValueAtRisk,
}
#[napi]
impl ConditionalValueAtRiskNode {
#[napi(constructor)]
pub fn new(period: u32, confidence: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::ConditionalValueAtRisk::new(period as usize, confidence).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 = "ProfitFactor")]
pub struct ProfitFactorNode {
inner: wc::ProfitFactor,
}
#[napi]
impl ProfitFactorNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ProfitFactor::new(period as usize).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 = "GainLossRatio")]
pub struct GainLossRatioNode {
inner: wc::GainLossRatio,
}
#[napi]
impl GainLossRatioNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::GainLossRatio::new(period as usize).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 = "RecoveryFactor")]
pub struct RecoveryFactorNode {
inner: wc::RecoveryFactor,
}
impl Default for RecoveryFactorNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl RecoveryFactorNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::RecoveryFactor::new(),
}
}
#[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 = "KellyCriterion")]
pub struct KellyCriterionNode {
inner: wc::KellyCriterion,
}
#[napi]
impl KellyCriterionNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::KellyCriterion::new(period as usize).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
}
}
// --- Two-series (asset, benchmark) indicators ---
//
// Family 12 (statistik-regression, PR #51) introduces a
// `node_pair_indicator!` macro for Pearson / Beta / Spearman. Family 12 is
// not yet in main, so Family 15 inlines its pair wrappers below by hand.
// When PR #51 lands, the merge conflict on this file is resolved by keeping
// the macro from Family 12 and re-using it for Treynor / IR / Alpha.
#[napi(js_name = "TreynorRatio")]
pub struct TreynorRatioNode {
inner: wc::TreynorRatio,
}
#[napi]
impl TreynorRatioNode {
#[napi(constructor)]
pub fn new(period: u32, risk_free: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::TreynorRatio::new(period as usize, risk_free).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
#[napi]
pub fn batch(&mut self, asset: Vec<f64>, benchmark: Vec<f64>) -> napi::Result<Vec<f64>> {
if asset.len() != benchmark.len() {
return Err(NapiError::from_reason(
"asset and benchmark must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[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 = "InformationRatio")]
pub struct InformationRatioNode {
inner: wc::InformationRatio,
}
#[napi]
impl InformationRatioNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::InformationRatio::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
#[napi]
pub fn batch(&mut self, asset: Vec<f64>, benchmark: Vec<f64>) -> napi::Result<Vec<f64>> {
if asset.len() != benchmark.len() {
return Err(NapiError::from_reason(
"asset and benchmark must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[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 = "Alpha")]
pub struct AlphaNode {
inner: wc::Alpha,
}
#[napi]
impl AlphaNode {
#[napi(constructor)]
pub fn new(period: u32, risk_free: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Alpha::new(period as usize, risk_free).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
#[napi]
pub fn batch(&mut self, asset: Vec<f64>, benchmark: Vec<f64>) -> napi::Result<Vec<f64>> {
if asset.len() != benchmark.len() {
return Err(NapiError::from_reason(
"asset and benchmark must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[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
}
}
+36
View File
@@ -235,6 +235,24 @@ from ._wickra import (
SpinningTop,
ThreeInside,
ThreeOutside,
# Risk / Performance
SharpeRatio,
SortinoRatio,
CalmarRatio,
OmegaRatio,
MaxDrawdown,
AverageDrawdown,
DrawdownDuration,
PainIndex,
ValueAtRisk,
ConditionalValueAtRisk,
ProfitFactor,
GainLossRatio,
RecoveryFactor,
KellyCriterion,
TreynorRatio,
InformationRatio,
Alpha,
)
__all__ = [
@@ -449,4 +467,22 @@ __all__ = [
"SpinningTop",
"ThreeInside",
"ThreeOutside",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
"CalmarRatio",
"OmegaRatio",
"MaxDrawdown",
"AverageDrawdown",
"DrawdownDuration",
"PainIndex",
"ValueAtRisk",
"ConditionalValueAtRisk",
"ProfitFactor",
"GainLossRatio",
"RecoveryFactor",
"KellyCriterion",
"TreynorRatio",
"InformationRatio",
"Alpha",
]
+911
View File
@@ -11157,6 +11157,899 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySharpeRatio {
inner: wc::SharpeRatio,
}
#[pymethods]
impl PySharpeRatio {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SharpeRatio::new(period, risk_free).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"SharpeRatio(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
#[pyclass(name = "SortinoRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySortinoRatio {
inner: wc::SortinoRatio,
}
#[pymethods]
impl PySortinoRatio {
#[new]
#[pyo3(signature = (period, mar=0.0))]
fn new(period: usize, mar: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SortinoRatio::new(period, mar).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn mar(&self) -> f64 {
self.inner.mar()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"SortinoRatio(period={}, mar={})",
self.inner.period(),
self.inner.mar()
)
}
}
#[pyclass(name = "CalmarRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCalmarRatio {
inner: wc::CalmarRatio,
}
#[pymethods]
impl PyCalmarRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::CalmarRatio::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("CalmarRatio(period={})", self.inner.period())
}
}
#[pyclass(name = "OmegaRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyOmegaRatio {
inner: wc::OmegaRatio,
}
#[pymethods]
impl PyOmegaRatio {
#[new]
#[pyo3(signature = (period, threshold=0.0))]
fn new(period: usize, threshold: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::OmegaRatio::new(period, threshold).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn threshold(&self) -> f64 {
self.inner.threshold()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"OmegaRatio(period={}, threshold={})",
self.inner.period(),
self.inner.threshold()
)
}
}
#[pyclass(name = "MaxDrawdown", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMaxDrawdown {
inner: wc::MaxDrawdown,
}
#[pymethods]
impl PyMaxDrawdown {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MaxDrawdown::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("MaxDrawdown(period={})", self.inner.period())
}
}
#[pyclass(
name = "AverageDrawdown",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAverageDrawdown {
inner: wc::AverageDrawdown,
}
#[pymethods]
impl PyAverageDrawdown {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AverageDrawdown::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("AverageDrawdown(period={})", self.inner.period())
}
}
#[pyclass(
name = "DrawdownDuration",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyDrawdownDuration {
inner: wc::DrawdownDuration,
}
#[pymethods]
impl PyDrawdownDuration {
#[new]
fn new() -> Self {
Self {
inner: wc::DrawdownDuration::new(),
}
}
fn update(&mut self, value: f64) -> Option<u32> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let out: Vec<f64> = self
.inner
.batch(slice)
.into_iter()
.map(|v| v.map_or(f64::NAN, f64::from))
.collect();
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"DrawdownDuration()".to_string()
}
}
#[pyclass(name = "PainIndex", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPainIndex {
inner: wc::PainIndex,
}
#[pymethods]
impl PyPainIndex {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PainIndex::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("PainIndex(period={})", self.inner.period())
}
}
#[pyclass(name = "ValueAtRisk", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyValueAtRisk {
inner: wc::ValueAtRisk,
}
#[pymethods]
impl PyValueAtRisk {
#[new]
#[pyo3(signature = (period, confidence=0.95))]
fn new(period: usize, confidence: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::ValueAtRisk::new(period, confidence).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn confidence(&self) -> f64 {
self.inner.confidence()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"ValueAtRisk(period={}, confidence={})",
self.inner.period(),
self.inner.confidence()
)
}
}
#[pyclass(
name = "ConditionalValueAtRisk",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyConditionalValueAtRisk {
inner: wc::ConditionalValueAtRisk,
}
#[pymethods]
impl PyConditionalValueAtRisk {
#[new]
#[pyo3(signature = (period, confidence=0.95))]
fn new(period: usize, confidence: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::ConditionalValueAtRisk::new(period, confidence).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn confidence(&self) -> f64 {
self.inner.confidence()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"ConditionalValueAtRisk(period={}, confidence={})",
self.inner.period(),
self.inner.confidence()
)
}
}
#[pyclass(name = "ProfitFactor", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyProfitFactor {
inner: wc::ProfitFactor,
}
#[pymethods]
impl PyProfitFactor {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ProfitFactor::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("ProfitFactor(period={})", self.inner.period())
}
}
#[pyclass(name = "GainLossRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGainLossRatio {
inner: wc::GainLossRatio,
}
#[pymethods]
impl PyGainLossRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GainLossRatio::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("GainLossRatio(period={})", self.inner.period())
}
}
#[pyclass(
name = "RecoveryFactor",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRecoveryFactor {
inner: wc::RecoveryFactor,
}
#[pymethods]
impl PyRecoveryFactor {
#[new]
fn new() -> Self {
Self {
inner: wc::RecoveryFactor::new(),
}
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"RecoveryFactor()".to_string()
}
}
#[pyclass(
name = "KellyCriterion",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyKellyCriterion {
inner: wc::KellyCriterion,
}
#[pymethods]
impl PyKellyCriterion {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KellyCriterion::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("KellyCriterion(period={})", self.inner.period())
}
}
// --- Pair (asset, benchmark) indicators ---
#[pyclass(name = "TreynorRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTreynorRatio {
inner: wc::TreynorRatio,
}
#[pymethods]
impl PyTreynorRatio {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"TreynorRatio(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
#[pyclass(
name = "InformationRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyInformationRatio {
inner: wc::InformationRatio,
}
#[pymethods]
impl PyInformationRatio {
#[new]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::InformationRatio::new(period).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("InformationRatio(period={})", self.inner.period())
}
}
#[pyclass(name = "Alpha", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAlpha {
inner: wc::Alpha,
}
#[pymethods]
impl PyAlpha {
#[new]
#[pyo3(signature = (period, risk_free=0.0))]
fn new(period: usize, risk_free: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Alpha::new(period, risk_free).map_err(map_err)?,
})
}
fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
asset: PyReadonlyArray1<'py, f64>,
benchmark: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let a = asset
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let b = benchmark
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if a.len() != b.len() {
return Err(PyValueError::new_err(
"asset and benchmark must have equal length",
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn risk_free(&self) -> f64 {
self.inner.risk_free()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"Alpha(period={}, risk_free={})",
self.inner.period(),
self.inner.risk_free()
)
}
}
// ============================== Module ==============================
#[pymodule]
@@ -11364,5 +12257,23 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySpinningTop>()?;
m.add_class::<PyThreeInside>()?;
m.add_class::<PyThreeOutside>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
m.add_class::<PyCalmarRatio>()?;
m.add_class::<PyOmegaRatio>()?;
m.add_class::<PyMaxDrawdown>()?;
m.add_class::<PyAverageDrawdown>()?;
m.add_class::<PyDrawdownDuration>()?;
m.add_class::<PyPainIndex>()?;
m.add_class::<PyValueAtRisk>()?;
m.add_class::<PyConditionalValueAtRisk>()?;
m.add_class::<PyProfitFactor>()?;
m.add_class::<PyGainLossRatio>()?;
m.add_class::<PyRecoveryFactor>()?;
m.add_class::<PyKellyCriterion>()?;
m.add_class::<PyTreynorRatio>()?;
m.add_class::<PyInformationRatio>()?;
m.add_class::<PyAlpha>()?;
Ok(())
}
+127
View File
@@ -332,6 +332,133 @@ def test_obv_cumulative_known_sequence():
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
# --- Family 15: Risk / Performance ---------------------------------------
def test_sharpe_ratio_known_window():
# returns [0.01, 0.02, 0.03, 0.04], rf = 0; mean = 0.025;
# sample-var = 0.000166...; Sharpe = 0.025 / sqrt(var).
out = ta.SharpeRatio(4, 0.0).batch(np.array([0.01, 0.02, 0.03, 0.04]))
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_sortino_ratio_known_window():
# returns [-0.02, 0.01, -0.01, 0.03], mar = 0; mean = 0.0025;
# downside_sq = 0.0005; dd = sqrt(0.0005/4); Sortino = 0.0025/dd.
out = ta.SortinoRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
expected = 0.0025 / math.sqrt(0.000_125)
assert math.isclose(out[3], expected, rel_tol=1e-9)
def test_max_drawdown_known_window():
# window [100, 120, 90] -> peak 120, trough 90 -> 25% drawdown.
out = ta.MaxDrawdown(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25, abs_tol=1e-12)
def test_pain_index_known_window():
# dd[0..2] = 0, 0, 0.25; mean = 0.25/3.
out = ta.PainIndex(3).batch(np.array([100.0, 120.0, 90.0]))
assert math.isclose(out[2], 0.25 / 3.0, abs_tol=1e-12)
def test_profit_factor_known_window():
# gains 0.05, losses 0.03 -> PF = 5/3.
out = ta.ProfitFactor(4).batch(np.array([0.02, -0.01, 0.03, -0.02]))
assert math.isclose(out[3], 5.0 / 3.0, rel_tol=1e-9)
def test_gain_loss_ratio_known_window():
# avg_win 0.03, avg_loss 0.02 -> GLR = 1.5.
out = ta.GainLossRatio(4).batch(np.array([0.02, -0.01, 0.04, -0.03]))
assert math.isclose(out[3], 1.5, rel_tol=1e-9)
def test_omega_ratio_known_window():
# gains 0.04, losses 0.03 -> Omega = 4/3.
out = ta.OmegaRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
assert math.isclose(out[3], 4.0 / 3.0, rel_tol=1e-9)
def test_kelly_criterion_known_window():
# n_win=n_loss=2, payoff=2 -> Kelly = 0.5 - 0.5/2 = 0.25.
out = ta.KellyCriterion(4).batch(np.array([0.02, 0.04, -0.01, -0.02]))
assert math.isclose(out[3], 0.25, rel_tol=1e-9)
def test_drawdown_duration_under_water_counter():
out = ta.DrawdownDuration().batch(np.array([100.0, 95.0, 90.0, 85.0]))
np.testing.assert_allclose(out, [0.0, 1.0, 2.0, 3.0])
def test_recovery_factor_known_path():
# Start 100, peak 110, trough 88 -> max_dd = 0.20; end 130 ->
# net_return = 0.30 -> Recovery = 1.5.
prices = np.array([100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0])
out = ta.RecoveryFactor().batch(prices)
assert math.isclose(out[-1], 1.5, rel_tol=1e-9)
def test_alpha_perfect_capm_fit_yields_zero():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.0, abs_tol=1e-12)
def test_alpha_additive_offset_recovered():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = bench + 0.005
out = ta.Alpha(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], 0.005, rel_tol=1e-9)
def test_treynor_ratio_known_window():
bench = np.array([0.01 * i for i in range(1, 21)])
asset = 2.0 * bench
out = ta.TreynorRatio(20, 0.0).batch(asset, bench)
assert math.isclose(out[-1], bench.mean(), rel_tol=1e-9)
def test_information_ratio_known_window():
asset = np.array([0.02, 0.04, 0.06, 0.08])
bench = np.array([0.01, 0.02, 0.03, 0.04])
out = ta.InformationRatio(4).batch(asset, bench)
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
out = ta.ValueAtRisk(10, 0.95).batch(returns)
assert math.isclose(out[-1], 0.0455, rel_tol=1e-9)
def test_conditional_value_at_risk_known_window():
# tail = {-0.10}; CVaR = 0.10.
returns = np.array([i * 0.01 for i in range(-10, 10)])
out = ta.ConditionalValueAtRisk(20, 0.95).batch(returns)
assert math.isclose(out[-1], 0.10, rel_tol=1e-9)
def test_calmar_ratio_known_path():
# returns [0.10, -0.20, 0.05]; equity 1.0->1.10->0.88->0.924;
# mdd = 0.20; mean = -0.01666...; Calmar = mean / 0.20.
out = ta.CalmarRatio(3).batch(np.array([0.10, -0.20, 0.05]))
expected = ((0.10 - 0.20 + 0.05) / 3.0) / 0.20
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_average_drawdown_known_window():
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
# mean = (0.25 + 10/120) / 4.
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
expected = (0.25 + 10.0 / 120.0) / 4.0
assert math.isclose(out[-1], expected, rel_tol=1e-12)
def test_value_area_concentrated_volume_locates_poc():
# Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume
# at price 110. POC must fall inside the high-volume bar's [low, high]
+47 -2
View File
@@ -17,13 +17,17 @@ import wickra as ta
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
"""Compare two float arrays treating NaN positions as equal."""
"""Compare two float arrays treating NaN and matching-sign inf positions as equal."""
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
if a.shape != b.shape:
return False
both_nan = np.isnan(a) & np.isnan(b)
return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol))
both_inf_same = np.isinf(a) & np.isinf(b) & (np.sign(a) == np.sign(b))
skip = both_nan | both_inf_same
with np.errstate(invalid="ignore"):
diff = np.abs(a - b)
return bool(np.all(np.where(skip, 0.0, diff) <= tol))
@pytest.fixture
@@ -106,6 +110,22 @@ SCALAR = [
(ta.MedianAbsoluteDeviation, (20,)),
(ta.Autocorrelation, (20, 1)),
(ta.HurstExponent, (40, 4)),
# Family 15 — Risk / Performance (scalar f64 input = period return or
# equity sample).
(ta.SharpeRatio, (20, 0.0)),
(ta.SortinoRatio, (20, 0.0)),
(ta.CalmarRatio, (20,)),
(ta.OmegaRatio, (20, 0.0)),
(ta.MaxDrawdown, (20,)),
(ta.AverageDrawdown, (20,)),
(ta.DrawdownDuration, ()),
(ta.PainIndex, (20,)),
(ta.ValueAtRisk, (20, 0.95)),
(ta.ConditionalValueAtRisk, (20, 0.95)),
(ta.ProfitFactor, (20,)),
(ta.GainLossRatio, (20,)),
(ta.RecoveryFactor, ()),
(ta.KellyCriterion, (20,)),
]
@@ -133,6 +153,31 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
]
@pytest.mark.parametrize("cls, args", PAIR, ids=[c.__name__ for c, _ in PAIR])
def test_pair_streaming_matches_batch(cls, args, sine_prices):
asset = np.ascontiguousarray(sine_prices.astype(np.float64))
bench = np.ascontiguousarray((sine_prices * 0.7 + 0.001).astype(np.float64))
batch = cls(*args).batch(asset, bench)
assert batch.shape == asset.shape
assert batch.dtype == np.float64
streamer = cls(*args)
streamed = []
for a, b in zip(asset, bench):
v = streamer.update(float(a), float(b))
streamed.append(math.nan if v is None else float(v))
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
+238
View File
@@ -6440,3 +6440,241 @@ mod tests {
);
}
}
// ============================== Family 15: Risk / Performance ==============================
// Most metrics need fallible `new` (period >= 2), so they're written by hand
// rather than going through `wasm_scalar_indicator!`. Single-parameter helpers
// reuse the same patterns as the rest of the file.
wasm_scalar_indicator!(WasmCalmarRatio, "CalmarRatio", wc::CalmarRatio, period: usize);
wasm_scalar_indicator!(WasmMaxDrawdown, "MaxDrawdown", wc::MaxDrawdown, period: usize);
wasm_scalar_indicator!(WasmAverageDrawdown, "AverageDrawdown", wc::AverageDrawdown, period: usize);
wasm_scalar_indicator!(WasmPainIndex, "PainIndex", wc::PainIndex, period: usize);
wasm_scalar_indicator!(WasmProfitFactor, "ProfitFactor", wc::ProfitFactor, period: usize);
wasm_scalar_indicator!(WasmGainLossRatio, "GainLossRatio", wc::GainLossRatio, period: usize);
wasm_scalar_indicator!(WasmKellyCriterion, "KellyCriterion", wc::KellyCriterion, period: usize);
wasm_scalar_indicator!(WasmSharpeRatio, "SharpeRatio", wc::SharpeRatio, period: usize, risk_free: f64);
wasm_scalar_indicator!(WasmSortinoRatio, "SortinoRatio", wc::SortinoRatio, period: usize, mar: f64);
wasm_scalar_indicator!(WasmOmegaRatio, "OmegaRatio", wc::OmegaRatio, period: usize, threshold: f64);
wasm_scalar_indicator!(WasmValueAtRisk, "ValueAtRisk", wc::ValueAtRisk, period: usize, confidence: f64);
wasm_scalar_indicator!(WasmConditionalValueAtRisk, "ConditionalValueAtRisk", wc::ConditionalValueAtRisk, period: usize, confidence: f64);
// --- DrawdownDuration: u32 output, no constructor args ---
#[wasm_bindgen(js_name = DrawdownDuration)]
pub struct WasmDrawdownDuration {
inner: wc::DrawdownDuration,
}
impl Default for WasmDrawdownDuration {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = DrawdownDuration)]
impl WasmDrawdownDuration {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmDrawdownDuration {
Self {
inner: wc::DrawdownDuration::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<u32> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let out: Vec<f64> = prices
.iter()
.map(|p| self.inner.update(*p).map_or(f64::NAN, f64::from))
.collect();
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// --- RecoveryFactor: no constructor args ---
#[wasm_bindgen(js_name = RecoveryFactor)]
pub struct WasmRecoveryFactor {
inner: wc::RecoveryFactor,
}
impl Default for WasmRecoveryFactor {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = RecoveryFactor)]
impl WasmRecoveryFactor {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmRecoveryFactor {
Self {
inner: wc::RecoveryFactor::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let out = flatten(self.inner.batch(prices));
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// --- Two-series (asset, benchmark) indicators ---
//
// Family 12 (PR #51) introduces `wasm_pair_indicator!` for Pearson / Beta /
// Spearman. Family 12 is not in main, so Family 15 writes its three pair
// wrappers by hand here; merge with PR #51 keeps the macro and re-uses it.
#[wasm_bindgen(js_name = TreynorRatio)]
pub struct WasmTreynorRatio {
inner: wc::TreynorRatio,
}
#[wasm_bindgen(js_class = TreynorRatio)]
impl WasmTreynorRatio {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, risk_free: f64) -> Result<WasmTreynorRatio, JsError> {
Ok(Self {
inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?,
})
}
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
if asset.len() != benchmark.len() {
return Err(JsError::new("asset and benchmark must be equal length"));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = InformationRatio)]
pub struct WasmInformationRatio {
inner: wc::InformationRatio,
}
#[wasm_bindgen(js_class = InformationRatio)]
impl WasmInformationRatio {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmInformationRatio, JsError> {
Ok(Self {
inner: wc::InformationRatio::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
if asset.len() != benchmark.len() {
return Err(JsError::new("asset and benchmark must be equal length"));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = Alpha)]
pub struct WasmAlpha {
inner: wc::Alpha,
}
#[wasm_bindgen(js_class = Alpha)]
impl WasmAlpha {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, risk_free: f64) -> Result<WasmAlpha, JsError> {
Ok(Self {
inner: wc::Alpha::new(period, risk_free).map_err(map_err)?,
})
}
pub fn update(&mut self, asset: f64, benchmark: f64) -> Option<f64> {
self.inner.update((asset, benchmark))
}
pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result<Float64Array, JsError> {
if asset.len() != benchmark.len() {
return Err(JsError::new("asset and benchmark must be equal length"));
}
let mut out = Vec::with_capacity(asset.len());
for i in 0..asset.len() {
out.push(
self.inner
.update((asset[i], benchmark[i]))
.unwrap_or(f64::NAN),
);
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Rolling Jensen's Alpha (CAPM).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Jensen's Alpha.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// Beta = cov(asset, bench) / var(bench)
/// Alpha = mean(asset) ( risk_free + Beta · (mean(bench) risk_free) )
/// ```
///
/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
/// performance that cannot be explained by simple exposure to the
/// benchmark. A positive alpha indicates outperformance net of the market
/// premium implied by the asset's beta; negative alpha is the opposite.
///
/// Population covariance and variance are used (matching common
/// implementations in pandas-ta / quantstats); the rolling estimator stays
/// unbiased in the steady state for fixed `period`.
///
/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
/// `alpha = mean(asset) risk_free` — the asset's mean excess return, with
/// no market-risk adjustment, since the regression slope is undefined.
///
/// Each `update` is O(1).
#[derive(Debug, Clone)]
pub struct Alpha {
period: usize,
risk_free: f64,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Alpha {
/// Construct a new rolling Alpha.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "alpha needs period >= 2",
});
}
Ok(Self {
period,
risk_free,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured per-period risk-free rate.
pub const fn risk_free(&self) -> f64 {
self.risk_free
}
}
impl Indicator for Alpha {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n) - mean_b * mean_b;
if var_b <= 0.0 {
// Undefined beta: report unadjusted excess.
return Some(mean_a - self.risk_free);
}
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
let beta = cov_ab / var_b;
Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Alpha"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
Alpha::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let a = Alpha::new(20, 0.001).unwrap();
assert_eq!(a.period(), 20);
assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(a.name(), "Alpha");
assert_eq!(a.warmup_period(), 20);
}
#[test]
fn capm_perfect_fit_yields_zero_alpha() {
// asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
// CAPM-implied return matches the asset's mean perfectly.
let mut a = Alpha::new(20, 0.0).unwrap();
let inputs: Vec<(f64, f64)> = (1..=20)
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
.collect();
let out = a.batch(&inputs);
assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn constant_alpha_offset_recovered() {
// asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
// Expected alpha = 0.005.
let mut a = Alpha::new(20, 0.0).unwrap();
let inputs: Vec<(f64, f64)> = (1..=20)
.map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
.collect();
let out = a.batch(&inputs);
assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_falls_back_to_excess_return() {
// Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
let mut a = Alpha::new(4, 0.001).unwrap();
let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut a = Alpha::new(3, 0.0).unwrap();
assert_eq!(a.update((f64::NAN, 0.0)), None);
assert_eq!(a.update((0.0, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut a = Alpha::new(3, 0.0).unwrap();
a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update((0.01, 0.005)), None);
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<(f64, f64)> = (0..50)
.map(|i| {
let b = (f64::from(i) * 0.2).sin() * 0.01;
(1.5 * b + 0.002, b)
})
.collect();
let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
let mut s = Alpha::new(10, 0.0).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,172 @@
//! Rolling Average Drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Average Drawdown.
///
/// Input is treated as an equity-curve sample. The indicator scans the
/// trailing window of `period` values, tracks the running peak inside the
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
/// "pain" of being under water):
///
/// ```text
/// drawdown_t = (peak_t equity_t) / peak_t (running peak inside window)
/// AvgDD = mean(drawdown_t over window)
/// ```
///
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
/// for the same metric exposed under its conventional label.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct AverageDrawdown {
period: usize,
window: VecDeque<f64>,
}
impl AverageDrawdown {
/// Construct a new rolling Average Drawdown.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AverageDrawdown {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
}
}
Some(sum_dd / self.period as f64)
}
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 {
"AverageDrawdown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AverageDrawdown::new(10).unwrap();
assert_eq!(a.period(), 10);
assert_eq!(a.name(), "AverageDrawdown");
assert_eq!(a.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut a = AverageDrawdown::new(5).unwrap();
let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// window [100, 120, 90, 110]:
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
// avg = (.25 + .0833...) / 4 = .0833...
let mut a = AverageDrawdown::new(4).unwrap();
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut a = AverageDrawdown::new(3).unwrap();
assert_eq!(a.update(f64::NAN), None);
assert_eq!(a.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut a = AverageDrawdown::new(3).unwrap();
a.batch(&[100.0, 90.0, 110.0]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
let mut s = AverageDrawdown::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
let mut a = AverageDrawdown::new(3).unwrap();
let out = a.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,202 @@
//! Rolling Calmar Ratio — return over max drawdown.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Calmar Ratio.
///
/// Input is treated as a single period return. Over the trailing window of
/// `period` returns the indicator reconstructs the implied equity curve
/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
/// divides the mean return by that drawdown:
///
/// ```text
/// equity_t = ∏(1 + r_i) for i in window up to t
/// mdd = max peak-to-trough decline of equity over window
/// Calmar = mean(returns) / mdd
/// ```
///
/// If the drawdown is zero (monotonically non-decreasing equity in the
/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
///
/// The equity curve is recomputed inside the window each `update`, which
/// keeps each call O(period) — acceptable for typical backtest windows
/// (`period ≤ 252`).
///
/// # Example
///
/// ```
/// use wickra_core::{CalmarRatio, Indicator};
///
/// let mut cr = CalmarRatio::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CalmarRatio {
period: usize,
window: VecDeque<f64>,
sum: f64,
}
impl CalmarRatio {
/// Construct a new rolling Calmar Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "calmar ratio needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for CalmarRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Build equity curve and track the worst peak-to-trough drawdown.
let mut equity = 1.0_f64;
let mut peak = 1.0_f64;
let mut mdd = 0.0_f64;
for &r in &self.window {
equity *= 1.0 + r;
if equity > peak {
peak = equity;
}
// peak starts at 1.0 and never decreases, so peak > 0 by construction.
let dd = (peak - equity) / peak;
if dd > mdd {
mdd = dd;
}
}
if mdd == 0.0 {
return Some(0.0);
}
Some(mean / mdd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CalmarRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
CalmarRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = CalmarRatio::new(10).unwrap();
assert_eq!(c.period(), 10);
assert_eq!(c.name(), "CalmarRatio");
assert_eq!(c.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
// All positive returns -> no drawdown -> Calmar = 0 by convention.
let mut c = CalmarRatio::new(5).unwrap();
let out = c.batch(&[0.01; 10]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn reference_value() {
// returns = [0.10, -0.20, 0.05]
// equity: 1.0 -> 1.10 -> 0.88 -> 0.924
// peak 1.10, trough 0.88 -> mdd = 0.20.
// mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
// Calmar = -0.01666... / 0.20 ≈ -0.08333...
let mut c = CalmarRatio::new(3).unwrap();
let out = c.batch(&[0.10, -0.20, 0.05]);
let mean = (0.10 - 0.20 + 0.05) / 3.0;
let expected = mean / 0.20;
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut c = CalmarRatio::new(3).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = CalmarRatio::new(3).unwrap();
c.batch(&[0.10, -0.20, 0.05]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
.collect();
let batch = CalmarRatio::new(10).unwrap().batch(&returns);
let mut s = CalmarRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,221 @@
//! Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Conditional Value-at-Risk (Expected Shortfall).
///
/// Where [`crate::ValueAtRisk`] reports the loss at the lower-tail quantile,
/// `CVaR` averages **all** returns below that quantile — the expected loss
/// conditional on being in the bad tail:
///
/// ```text
/// q = 1 confidence
/// tail = returns over window with rank fraction ≤ q
/// CVaR = mean(tail) if mean is negative
/// CVaR = 0 otherwise
/// ```
///
/// The tail comprises the `floor(q · n)` smallest returns; if `floor` rounds
/// down to zero the smallest single return is used so the metric stays
/// defined for any `period ≥ 2`. Output is the magnitude of the expected
/// shortfall (sign-flipped to be non-negative). `CVaR` is by construction
/// `≥ VaR` because it averages losses *beyond* the `VaR` threshold.
///
/// Each `update` is O(period · log period).
///
/// # Example
///
/// ```
/// use wickra_core::{ConditionalValueAtRisk, Indicator};
///
/// let mut c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = c.update((f64::from(i) * 0.1).sin() * 0.02);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ConditionalValueAtRisk {
period: usize,
confidence: f64,
window: VecDeque<f64>,
}
impl ConditionalValueAtRisk {
/// Construct a new rolling `CVaR`.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
/// `confidence` is outside `(0, 1)`.
pub fn new(period: usize, confidence: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "conditional value-at-risk needs period >= 2",
});
}
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
return Err(Error::InvalidPeriod {
message: "confidence must lie strictly between 0 and 1",
});
}
Ok(Self {
period,
confidence,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured confidence level.
pub const fn confidence(&self) -> f64 {
self.confidence
}
}
impl Indicator for ConditionalValueAtRisk {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = 1.0 - self.confidence;
let n = sorted.len();
// Number of samples in the tail. Floor, with a min of 1 so the
// expectation is always defined.
let k = ((q * n as f64).floor() as usize).max(1);
let tail = &sorted[..k];
let mean = tail.iter().sum::<f64>() / k as f64;
Some((-mean).max(0.0))
}
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 {
"ConditionalValueAtRisk"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
ConditionalValueAtRisk::new(1, 0.95),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ConditionalValueAtRisk::new(20, 1.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
assert_eq!(c.period(), 100);
assert_relative_eq!(c.confidence(), 0.95, epsilon = 1e-12);
assert_eq!(c.name(), "ConditionalValueAtRisk");
assert_eq!(c.warmup_period(), 100);
}
#[test]
fn reference_value() {
// 20 returns -10..9 (each *0.01); confidence 0.95.
// q = 0.05, n = 20, k = floor(0.05*20) = 1.
// Tail = {-0.10}, CVaR = 0.10.
let mut c = ConditionalValueAtRisk::new(20, 0.95).unwrap();
let returns: Vec<f64> = (-10..10).map(|i| f64::from(i) * 0.01).collect();
let out = c.batch(&returns);
assert_relative_eq!(out[19].unwrap(), 0.10, epsilon = 1e-9);
}
#[test]
fn cvar_geq_var_on_same_window() {
// Sanity: with confidence 0.9, the tail of 10 returns has 1 sample;
// VaR uses interpolation between 0 and 1, so CVaR (mean of just the
// worst) >= VaR.
use crate::ValueAtRisk;
let returns: Vec<f64> = vec![
-0.05, -0.02, -0.01, 0.0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05,
];
let mut v = ValueAtRisk::new(10, 0.9).unwrap();
let mut c = ConditionalValueAtRisk::new(10, 0.9).unwrap();
let v_out = v.batch(&returns);
let c_out = c.batch(&returns);
let var = v_out[9].unwrap();
let cvar = c_out[9].unwrap();
assert!(cvar >= var - 1e-12, "CVaR {cvar} should be >= VaR {var}");
}
#[test]
fn all_positive_returns_yield_zero() {
let mut c = ConditionalValueAtRisk::new(5, 0.95).unwrap();
let out = c.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
assert_eq!(c.update(f64::NAN), None);
assert_eq!(c.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
c.batch(&[-0.01, -0.02, -0.03]);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
let batch = ConditionalValueAtRisk::new(10, 0.95)
.unwrap()
.batch(&returns);
let mut s = ConditionalValueAtRisk::new(10, 0.95).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,174 @@
//! Drawdown Duration — bars since the last all-time peak ("time under water").
use crate::traits::Indicator;
/// Cumulative drawdown duration in bars.
///
/// Each `update` receives one equity-curve sample. The indicator tracks the
/// **running all-time peak** seen since construction (or last `reset`) and
/// reports how many bars have elapsed since that peak was set:
///
/// ```text
/// peak_t = max(input over [0..=t])
/// duration_t = bars elapsed since peak_t was first set
/// ```
///
/// A new peak resets the duration to `0`. As long as the series stays under
/// water the duration grows linearly with each bar.
///
/// The indicator emits a value on every bar (no warmup beyond the first
/// input) and runs in O(1) per `update`.
///
/// # Example
///
/// ```
/// use wickra_core::{DrawdownDuration, Indicator};
///
/// let mut dd = DrawdownDuration::new();
/// assert_eq!(dd.update(100.0), Some(0)); // first bar -> new peak
/// assert_eq!(dd.update(95.0), Some(1)); // 1 bar under water
/// assert_eq!(dd.update(90.0), Some(2)); // 2 bars under water
/// assert_eq!(dd.update(110.0), Some(0)); // new peak -> reset
/// ```
#[derive(Debug, Clone, Default)]
pub struct DrawdownDuration {
peak: f64,
bars_under_water: u32,
seen: bool,
}
impl DrawdownDuration {
/// Construct a new Drawdown Duration tracker.
pub const fn new() -> Self {
Self {
peak: f64::NEG_INFINITY,
bars_under_water: 0,
seen: false,
}
}
/// Bars elapsed since the running all-time peak was set.
pub const fn value(&self) -> Option<u32> {
if self.seen {
Some(self.bars_under_water)
} else {
None
}
}
}
impl Indicator for DrawdownDuration {
type Input = f64;
type Output = u32;
fn update(&mut self, input: f64) -> Option<u32> {
if !input.is_finite() {
return self.value();
}
if !self.seen || input >= self.peak {
self.peak = input;
self.bars_under_water = 0;
} else {
self.bars_under_water = self.bars_under_water.saturating_add(1);
}
self.seen = true;
Some(self.bars_under_water)
}
fn reset(&mut self) {
self.peak = f64::NEG_INFINITY;
self.bars_under_water = 0;
self.seen = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.seen
}
fn name(&self) -> &'static str {
"DrawdownDuration"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut d = DrawdownDuration::new();
assert_eq!(d.name(), "DrawdownDuration");
assert_eq!(d.warmup_period(), 1);
assert_eq!(d.value(), None);
d.update(100.0);
assert_eq!(d.value(), Some(0));
}
#[test]
fn first_bar_is_peak() {
let mut d = DrawdownDuration::new();
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn under_water_counter_increments() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(90.0), Some(1));
assert_eq!(d.update(80.0), Some(2));
assert_eq!(d.update(85.0), Some(3));
}
#[test]
fn new_peak_resets_counter() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
d.update(80.0);
assert_eq!(d.update(105.0), Some(0));
assert_eq!(d.update(95.0), Some(1));
}
#[test]
fn equal_value_is_treated_as_peak() {
let mut d = DrawdownDuration::new();
d.update(100.0);
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn ignores_non_finite_input() {
let mut d = DrawdownDuration::new();
d.update(100.0);
d.update(90.0);
let v = d.value();
assert_eq!(d.update(f64::NAN), v);
assert_eq!(d.update(f64::INFINITY), v);
}
#[test]
fn reset_clears_state() {
let mut d = DrawdownDuration::new();
d.batch(&[100.0, 90.0, 80.0]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(100.0), Some(0));
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..30)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let batch = DrawdownDuration::new().batch(&prices);
let mut s = DrawdownDuration::new();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,184 @@
//! Rolling Gain/Loss Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Gain/Loss Ratio.
///
/// Over the trailing window:
///
/// ```text
/// avg_win = mean(r for r in window if r > 0)
/// avg_loss = mean(r for r in window if r < 0)
/// GLR = avg_win / avg_loss
/// ```
///
/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages
/// them: it answers "for the typical winning bar, how big is the win
/// compared to the typical losing bar?". If there are no losers the
/// indicator returns `f64::INFINITY`; if there are no winners and no losers
/// it returns `0.0`.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct GainLossRatio {
period: usize,
window: VecDeque<f64>,
}
impl GainLossRatio {
/// Construct a new rolling Gain/Loss Ratio.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for GainLossRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sum_win = 0.0_f64;
let mut n_win = 0_u32;
let mut sum_loss = 0.0_f64;
let mut n_loss = 0_u32;
for &r in &self.window {
if r > 0.0 {
sum_win += r;
n_win += 1;
} else if r < 0.0 {
sum_loss += -r;
n_loss += 1;
}
}
if n_loss == 0 {
return Some(if n_win == 0 { 0.0 } else { f64::INFINITY });
}
let avg_win = if n_win == 0 {
0.0
} else {
sum_win / f64::from(n_win)
};
let avg_loss = sum_loss / f64::from(n_loss);
Some(avg_win / avg_loss)
}
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 {
"GainLossRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let g = GainLossRatio::new(10).unwrap();
assert_eq!(g.period(), 10);
assert_eq!(g.name(), "GainLossRatio");
assert_eq!(g.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, -0.01, 0.04, -0.03]
// avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
let mut g = GainLossRatio::new(4).unwrap();
let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
}
#[test]
fn no_losses_yields_infinity() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.01, 0.02, 0.03]);
assert!(out[2].unwrap().is_infinite());
}
#[test]
fn flat_window_yields_zero() {
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut g = GainLossRatio::new(3).unwrap();
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(f64::INFINITY), None);
}
#[test]
fn no_wins_but_losses_yields_zero() {
// Window with only losses: avg_win is 0, GLR = 0.
let mut g = GainLossRatio::new(3).unwrap();
let out = g.batch(&[-0.01, -0.02, -0.03]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut g = GainLossRatio::new(3).unwrap();
g.batch(&[0.01, -0.02, 0.03]);
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = GainLossRatio::new(10).unwrap().batch(&returns);
let mut s = GainLossRatio::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,187 @@
//! Rolling Information Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Information Ratio.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// active_t = asset_t benchmark_t
/// tracking_error = stddev(active over window) (sample)
/// IR = mean(active) / tracking_error
/// ```
///
/// The Information Ratio quantifies skill in beating a benchmark per unit
/// of active-return volatility. A high IR means consistent (low-noise)
/// outperformance; a near-zero IR means the asset moves with the benchmark
/// regardless of any small alpha.
///
/// If the tracking error is zero (asset perfectly tracks the benchmark over
/// the window) the indicator returns `0.0` rather than `NaN`.
///
/// Each `update` is O(1).
#[derive(Debug, Clone)]
pub struct InformationRatio {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl InformationRatio {
/// Construct a new rolling Information Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "information ratio needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for InformationRatio {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
let active = a - b;
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(active);
self.sum += active;
self.sum_sq += active * active;
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) / (n - 1.0)).max(0.0);
let te = var.sqrt();
if te == 0.0 {
return Some(0.0);
}
Some(mean / te)
}
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 {
"InformationRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
InformationRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let i = InformationRatio::new(10).unwrap();
assert_eq!(i.period(), 10);
assert_eq!(i.name(), "InformationRatio");
assert_eq!(i.warmup_period(), 10);
}
#[test]
fn perfect_tracking_yields_zero() {
// asset == benchmark every bar -> active = 0 -> te = 0 -> 0.
let mut i = InformationRatio::new(5).unwrap();
let inputs: Vec<(f64, f64)> = (0..5)
.map(|j| (f64::from(j) * 0.01, f64::from(j) * 0.01))
.collect();
let out = i.batch(&inputs);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn reference_value() {
// asset=[0.02,0.04,0.06,0.08], bench=[0.01,0.02,0.03,0.04].
// active=[0.01,0.02,0.03,0.04]; mean=0.025;
// var = ((0.01-.025)^2 + ... ) / 3 = 0.0001666...;
// te = sqrt(0.0001666...); IR = 0.025/te.
let mut i = InformationRatio::new(4).unwrap();
let inputs = vec![(0.02, 0.01), (0.04, 0.02), (0.06, 0.03), (0.08, 0.04)];
let out = i.batch(&inputs);
let expected = 0.025 / (0.000_166_666_666_666_666_67_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut i = InformationRatio::new(3).unwrap();
assert_eq!(i.update((f64::NAN, 0.01)), None);
assert_eq!(i.update((0.01, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut i = InformationRatio::new(3).unwrap();
i.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(i.is_ready());
i.reset();
assert!(!i.is_ready());
assert_eq!(i.update((0.01, 0.005)), None);
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<(f64, f64)> = (0..50)
.map(|j| {
let b = (f64::from(j) * 0.2).sin() * 0.01;
(b + 0.001, b)
})
.collect();
let batch = InformationRatio::new(10).unwrap().batch(&inputs);
let mut s = InformationRatio::new(10).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,202 @@
//! Rolling Kelly Criterion.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Kelly Criterion fraction.
///
/// Input is treated as a per-period (or per-trade) return. Over the trailing
/// window the indicator estimates the optimal capital fraction to allocate
/// using the **even-money** Kelly formula generalised by the payoff ratio:
///
/// ```text
/// win_rate = P(r > 0) over window
/// avg_win = mean(r for r > 0)
/// avg_loss = mean(r for r < 0)
/// payoff_ratio = avg_win / avg_loss
/// Kelly = win_rate (1 win_rate) / payoff_ratio
/// ```
///
/// The output is the recommended **fraction** of capital to bet (typically
/// `(0, 1)`; can go negative if the estimated edge is negative, in which
/// case the position should be reversed or sized to zero). Most
/// practitioners use a "half-Kelly" or "quarter-Kelly" multiplier in
/// practice to reduce variance — Wickra reports raw Kelly and leaves the
/// scaling to the caller.
///
/// Edge cases:
/// * No winners and no losers ⇒ `0.0` (no information).
/// * No losers (`payoff_ratio = ∞`) ⇒ Kelly collapses to the win rate.
/// * No winners but losers present ⇒ Kelly = `(1 0) / payoff = …`,
/// which is negative — bet nothing (or short).
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct KellyCriterion {
period: usize,
window: VecDeque<f64>,
}
impl KellyCriterion {
/// Construct a new rolling Kelly Criterion.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for KellyCriterion {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sum_win = 0.0_f64;
let mut n_win = 0_u32;
let mut sum_loss = 0.0_f64;
let mut n_loss = 0_u32;
for &r in &self.window {
if r > 0.0 {
sum_win += r;
n_win += 1;
} else if r < 0.0 {
sum_loss += -r;
n_loss += 1;
}
}
let n = self.period as f64;
let win_rate = f64::from(n_win) / n;
if n_loss == 0 {
// No losses in window: payoff ratio is infinite; Kelly collapses
// to the win rate (limit of w - (1-w)/r as r -> ∞).
return Some(win_rate);
}
let avg_loss = sum_loss / f64::from(n_loss);
if n_win == 0 {
// All losses: avg_win = 0 -> payoff = 0 -> -(1)/0 -> -inf.
// Bet nothing (or reverse); clamp to -1 for sanity.
return Some(-1.0);
}
let avg_win = sum_win / f64::from(n_win);
let payoff = avg_win / avg_loss;
Some(win_rate - (1.0 - win_rate) / payoff)
}
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 {
"KellyCriterion"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(KellyCriterion::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let k = KellyCriterion::new(10).unwrap();
assert_eq!(k.period(), 10);
assert_eq!(k.name(), "KellyCriterion");
assert_eq!(k.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, 0.04, -0.01, -0.02] (n=4).
// n_win=2, n_loss=2; win_rate = 0.5.
// avg_win=0.03, avg_loss=0.015, payoff=2.
// Kelly = 0.5 - (0.5/2) = 0.25.
let mut k = KellyCriterion::new(4).unwrap();
let out = k.batch(&[0.02, 0.04, -0.01, -0.02]);
assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-9);
}
#[test]
fn all_winners_returns_win_rate() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[0.01, 0.02, 0.03]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn all_losers_returns_negative_one() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[-0.01, -0.02, -0.03]);
assert_relative_eq!(out[2].unwrap(), -1.0, epsilon = 1e-12);
}
#[test]
fn flat_window_yields_zero() {
let mut k = KellyCriterion::new(3).unwrap();
let out = k.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut k = KellyCriterion::new(3).unwrap();
assert_eq!(k.update(f64::NAN), None);
assert_eq!(k.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut k = KellyCriterion::new(3).unwrap();
k.batch(&[0.01, -0.02, 0.03]);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = KellyCriterion::new(10).unwrap().batch(&returns);
let mut s = KellyCriterion::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,245 @@
//! Maximum Drawdown over a rolling window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Maximum Drawdown — the deepest peak-to-trough decline within the
/// trailing window.
///
/// The input is treated as an equity-curve sample (or any non-negative value
/// series). For each bar the indicator computes the largest fractional decline
/// from any prior peak inside the trailing `period`-bar window:
///
/// ```text
/// drawdown_t = (equity_t peak_t) / peak_t (a negative number)
/// MaxDrawdown = min(drawdown_t over window) (most-negative value)
/// ```
///
/// Output is the magnitude of the worst drawdown as a non-negative fraction
/// (`0.20` = 20 % drop from peak). A monotonically rising equity curve has a
/// max drawdown of `0`. Setting `period` greater than or equal to the number of
/// bars you will ever feed makes the metric effectively *cumulative* — the
/// indicator never forgets the global peak.
///
/// Each `update` is amortised O(1): the running peak is tracked with a
/// monotonically-decreasing deque.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MaxDrawdown};
///
/// let mut mdd = MaxDrawdown::new(10).unwrap();
/// // Equity peaks at 110 then drops to 88 — a 20% drawdown.
/// for v in [100.0, 110.0, 100.0, 95.0, 88.0, 90.0, 92.0, 95.0, 100.0, 105.0] {
/// mdd.update(v);
/// }
/// assert!((mdd.update(106.0).unwrap() - 0.20).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct MaxDrawdown {
period: usize,
count: u64,
/// Monotonically-decreasing deque of `(index, value)` over the trailing
/// window. Front is the trailing peak in O(1).
peak_dq: VecDeque<(u64, f64)>,
window: VecDeque<f64>,
last: Option<f64>,
}
impl MaxDrawdown {
/// Construct a new rolling Max Drawdown.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
count: 0,
peak_dq: VecDeque::with_capacity(period),
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured rolling-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for MaxDrawdown {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
self.count += 1;
// Drop tail entries dominated by the new value (running peak from the
// back side of the window).
while let Some(&(_, back)) = self.peak_dq.back() {
if back <= input {
self.peak_dq.pop_back();
} else {
break;
}
}
self.peak_dq.push_back((self.count, input));
// Window slide.
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
let window_lo = self.count.saturating_sub(self.period as u64 - 1);
while let Some(&(idx, _)) = self.peak_dq.front() {
if idx < window_lo {
self.peak_dq.pop_front();
} else {
break;
}
}
if self.window.len() < self.period {
return None;
}
// Scan the window for the deepest drawdown vs running peak so far.
let mut peak = f64::NEG_INFINITY;
let mut worst = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
let dd = (peak - v) / peak;
if dd > worst {
worst = dd;
}
}
}
self.last = Some(worst);
Some(worst)
}
fn reset(&mut self) {
self.count = 0;
self.peak_dq.clear();
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MaxDrawdown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut mdd = MaxDrawdown::new(10).unwrap();
assert_eq!(mdd.period(), 10);
assert_eq!(mdd.name(), "MaxDrawdown");
assert_eq!(mdd.value(), None);
assert_eq!(mdd.warmup_period(), 10);
for v in 1..=10 {
mdd.update(f64::from(v));
}
assert!(mdd.value().is_some());
}
#[test]
fn pure_uptrend_yields_zero() {
let mut mdd = MaxDrawdown::new(5).unwrap();
let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_drawdown() {
// Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown.
let mut mdd = MaxDrawdown::new(3).unwrap();
let out = mdd.batch(&[100.0, 120.0, 90.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut mdd = MaxDrawdown::new(4).unwrap();
let out = mdd.batch(&[50.0; 12]);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut mdd = MaxDrawdown::new(3).unwrap();
mdd.batch(&[100.0, 90.0, 80.0]);
let last = mdd.value();
assert_eq!(mdd.update(f64::NAN), last);
assert_eq!(mdd.update(f64::INFINITY), last);
}
#[test]
fn reset_clears_state() {
let mut mdd = MaxDrawdown::new(3).unwrap();
mdd.batch(&[100.0, 90.0, 80.0]);
assert!(mdd.is_ready());
mdd.reset();
assert!(!mdd.is_ready());
assert_eq!(mdd.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
let mut s = MaxDrawdown::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
// All-zero stream: peak is 0, division skipped, result stays 0.
let mut mdd = MaxDrawdown::new(3).unwrap();
let out = mdd.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
+34
View File
@@ -13,6 +13,7 @@ mod adx;
mod adxr;
mod alligator;
mod alma;
mod alpha;
mod anchored_vwap;
mod apo;
mod aroon;
@@ -21,12 +22,14 @@ mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_drawdown;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod beta;
mod bollinger;
mod bollinger_bandwidth;
mod calmar_ratio;
mod camarilla_pivots;
mod cci;
mod center_of_gravity;
@@ -40,6 +43,7 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cybernetic_cycle;
@@ -54,6 +58,7 @@ mod donchian;
mod donchian_stop;
mod double_bollinger;
mod dpo;
mod drawdown_duration;
mod ease_of_movement;
mod ehlers_stochastic;
mod elder_impulse;
@@ -67,6 +72,7 @@ mod fisher_transform;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod gain_loss_ratio;
mod garman_klass;
mod hammer;
mod hanging_man;
@@ -80,12 +86,14 @@ mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod kama;
mod kelly_criterion;
mod keltner;
mod kst;
mod kurtosis;
@@ -101,6 +109,7 @@ mod mama;
mod market_facilitation_index;
mod marubozu;
mod mass_index;
mod max_drawdown;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
@@ -110,7 +119,9 @@ mod morning_evening_star;
mod natr;
mod nvi;
mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -119,9 +130,11 @@ mod pgo;
mod piercing_dark_cloud;
mod pmo;
mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod r_squared;
mod recovery_factor;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -130,12 +143,14 @@ mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod sine_wave;
mod skewness;
mod sma;
mod smi;
mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod standard_error;
@@ -166,6 +181,7 @@ mod three_inside;
mod three_outside;
mod three_soldiers_or_crows;
mod tii;
mod treynor_ratio;
mod trima;
mod trix;
mod true_range;
@@ -177,6 +193,7 @@ mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod value_area;
mod value_at_risk;
mod variance;
mod vertical_horizontal_filter;
mod vidya;
@@ -210,6 +227,7 @@ pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
pub use alma::Alma;
pub use alpha::Alpha;
pub use anchored_vwap::AnchoredVwap;
pub use apo::Apo;
pub use aroon::{Aroon, AroonOutput};
@@ -218,12 +236,14 @@ pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use average_drawdown::AverageDrawdown;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use beta::Beta;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use center_of_gravity::CenterOfGravity;
@@ -237,6 +257,7 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cybernetic_cycle::CyberneticCycle;
@@ -251,6 +272,7 @@ pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use drawdown_duration::DrawdownDuration;
pub use ease_of_movement::EaseOfMovement;
pub use ehlers_stochastic::EhlersStochastic;
pub use elder_impulse::ElderImpulse;
@@ -264,6 +286,7 @@ pub use fisher_transform::FisherTransform;
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use gain_loss_ratio::GainLossRatio;
pub use garman_klass::GarmanKlassVolatility;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
@@ -277,12 +300,14 @@ pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
@@ -298,6 +323,7 @@ pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
pub use mass_index::MassIndex;
pub use max_drawdown::MaxDrawdown;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
@@ -307,7 +333,9 @@ pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -316,9 +344,11 @@ pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pmo::Pmo;
pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -327,12 +357,14 @@ pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use sine_wave::SineWave;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use standard_error::StandardError;
@@ -363,6 +395,7 @@ pub use three_inside::ThreeInside;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use tii::Tii;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
@@ -374,6 +407,7 @@ pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use value_at_risk::ValueAtRisk;
pub use variance::Variance;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
@@ -0,0 +1,194 @@
//! Rolling Omega Ratio — gain-to-loss ratio above a threshold.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Omega Ratio.
///
/// Over the trailing window of `period` returns and a target `threshold`:
///
/// ```text
/// gains = Σ max(0, r threshold)
/// losses = Σ max(0, threshold r)
/// Omega = gains / losses
/// ```
///
/// Omega expresses how many units of "above-threshold" return the strategy
/// produces per unit of "below-threshold" shortfall. By construction `Omega
/// ≥ 0`; a window where every return clears the threshold has zero losses and
/// the indicator returns `f64::INFINITY` (in keeping with the standard
/// definition). The Sharpe Ratio collapses risk into a single second-moment
/// number; Omega keeps the full shape of the loss tail.
///
/// Each `update` is O(period) because the partial sums are recomputed across
/// the window — adequate for typical backtest windows (`period ≤ 252`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, OmegaRatio};
///
/// let mut o = OmegaRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = o.update((f64::from(i) * 0.2).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct OmegaRatio {
period: usize,
threshold: f64,
window: VecDeque<f64>,
}
impl OmegaRatio {
/// Construct a new rolling Omega Ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, threshold: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
threshold,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured threshold (per-period).
pub const fn threshold(&self) -> f64 {
self.threshold
}
}
impl Indicator for OmegaRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut gains = 0.0_f64;
let mut losses = 0.0_f64;
for &r in &self.window {
let d = r - self.threshold;
if d >= 0.0 {
gains += d;
} else {
losses += -d;
}
}
if losses == 0.0 {
return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY });
}
Some(gains / losses)
}
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 {
"OmegaRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(OmegaRatio::new(0, 0.0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let o = OmegaRatio::new(10, 0.001).unwrap();
assert_eq!(o.period(), 10);
assert_relative_eq!(o.threshold(), 0.001, epsilon = 1e-12);
assert_eq!(o.name(), "OmegaRatio");
assert_eq!(o.warmup_period(), 10);
}
#[test]
fn all_above_threshold_yields_infinity() {
let mut o = OmegaRatio::new(4, 0.0).unwrap();
let out = o.batch(&[0.01, 0.02, 0.03, 0.04]);
assert!(out[3].unwrap().is_infinite());
}
#[test]
fn flat_at_threshold_yields_zero() {
// Every return equals threshold -> gains = losses = 0 -> 0 by
// convention.
let mut o = OmegaRatio::new(4, 0.01).unwrap();
let out = o.batch(&[0.01; 4]);
assert_eq!(out[3], Some(0.0));
}
#[test]
fn reference_value() {
// returns = [-0.02, 0.01, -0.01, 0.03], threshold = 0.
// gains = 0.01 + 0.03 = 0.04
// losses = 0.02 + 0.01 = 0.03
// Omega = 0.04 / 0.03 ≈ 1.3333...
let mut o = OmegaRatio::new(4, 0.0).unwrap();
let out = o.batch(&[-0.02, 0.01, -0.01, 0.03]);
assert_relative_eq!(out[3].unwrap(), 0.04 / 0.03, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut o = OmegaRatio::new(3, 0.0).unwrap();
assert_eq!(o.update(f64::NAN), None);
assert_eq!(o.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut o = OmegaRatio::new(3, 0.0).unwrap();
o.batch(&[0.01, -0.02, 0.005]);
assert!(o.is_ready());
o.reset();
assert!(!o.is_ready());
assert_eq!(o.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.4).sin() * 0.01).collect();
let batch = OmegaRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = OmegaRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,171 @@
//! Rolling Pain Index — mean depth of drawdowns.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Pain Index — Thomas Becker's continuous-pain risk measure.
///
/// Input is treated as an equity-curve sample. The Pain Index is the **mean**
/// drawdown depth over the trailing window of `period` bars, expressed as a
/// non-negative fraction:
///
/// ```text
/// peak_t = running max over window up to t
/// dd_t = (peak_t equity_t) / peak_t (0 if no drawdown)
/// PainIdx = mean(dd_t over window)
/// ```
///
/// Where Ulcer Index uses an RMS aggregation that punishes deep drawdowns
/// disproportionately, the Pain Index uses a plain arithmetic mean. The two
/// are normally similar; the Pain Index reads slightly lower on stresses with
/// a few large drawdowns and similar elsewhere.
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
pub struct PainIndex {
period: usize,
window: VecDeque<f64>,
}
impl PainIndex {
/// Construct a new rolling Pain Index.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PainIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
for &v in &self.window {
if v > peak {
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
}
}
Some(sum_dd / self.period as f64)
}
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 {
"PainIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = PainIndex::new(10).unwrap();
assert_eq!(p.period(), 10);
assert_eq!(p.name(), "PainIndex");
assert_eq!(p.warmup_period(), 10);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut p = PainIndex::new(5).unwrap();
let out = p.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// window [100, 120, 90]: peaks 100,120,120; dd: 0, 0, 0.25.
// Pain = 0.25 / 3 ≈ 0.08333...
let mut p = PainIndex::new(3).unwrap();
let out = p.batch(&[100.0, 120.0, 90.0]);
assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut p = PainIndex::new(3).unwrap();
assert_eq!(p.update(f64::NAN), None);
assert_eq!(p.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut p = PainIndex::new(3).unwrap();
p.batch(&[100.0, 90.0, 110.0]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.update(100.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = PainIndex::new(10).unwrap().batch(&prices);
let mut s = PainIndex::new(10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_yields_zero() {
let mut p = PainIndex::new(3).unwrap();
let out = p.batch(&[0.0_f64; 6]);
for v in out.into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,179 @@
//! Rolling Profit Factor.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Profit Factor.
///
/// Input is treated as a per-period return (or a per-trade P&L). Over the
/// trailing window:
///
/// ```text
/// gross_profit = Σ max(0, r) over window
/// gross_loss = Σ max(0, r) over window
/// PF = gross_profit / gross_loss
/// ```
///
/// `PF > 1` means the strategy made more than it lost in the window. If
/// there were no losing returns the gross loss is zero and the indicator
/// returns `f64::INFINITY` (or `0.0` when there were also no gains —
/// a flat window).
///
/// Each `update` is O(period).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, ProfitFactor};
///
/// let mut pf = ProfitFactor::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = pf.update((f64::from(i) * 0.2).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProfitFactor {
period: usize,
window: VecDeque<f64>,
}
impl ProfitFactor {
/// Construct a new rolling Profit Factor.
///
/// # 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),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ProfitFactor {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut gains = 0.0_f64;
let mut losses = 0.0_f64;
for &r in &self.window {
if r > 0.0 {
gains += r;
} else if r < 0.0 {
losses += -r;
}
}
if losses == 0.0 {
return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY });
}
Some(gains / losses)
}
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 {
"ProfitFactor"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(ProfitFactor::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = ProfitFactor::new(10).unwrap();
assert_eq!(p.period(), 10);
assert_eq!(p.name(), "ProfitFactor");
assert_eq!(p.warmup_period(), 10);
}
#[test]
fn reference_value() {
// returns = [0.02, -0.01, 0.03, -0.02]
// gains = 0.05, losses = 0.03, PF = 5/3.
let mut p = ProfitFactor::new(4).unwrap();
let out = p.batch(&[0.02, -0.01, 0.03, -0.02]);
assert_relative_eq!(out[3].unwrap(), 5.0 / 3.0, epsilon = 1e-9);
}
#[test]
fn no_losses_yields_infinity() {
let mut p = ProfitFactor::new(3).unwrap();
let out = p.batch(&[0.01, 0.02, 0.03]);
assert!(out[2].unwrap().is_infinite());
}
#[test]
fn flat_window_yields_zero() {
let mut p = ProfitFactor::new(3).unwrap();
let out = p.batch(&[0.0_f64; 3]);
assert_eq!(out[2], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut p = ProfitFactor::new(3).unwrap();
assert_eq!(p.update(f64::NAN), None);
assert_eq!(p.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut p = ProfitFactor::new(3).unwrap();
p.batch(&[0.01, -0.02, 0.03]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
let batch = ProfitFactor::new(10).unwrap().batch(&returns);
let mut s = ProfitFactor::new(10).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,212 @@
//! Recovery Factor — cumulative net return over max drawdown.
use crate::traits::Indicator;
/// Recovery Factor.
///
/// Input is treated as an equity-curve sample (e.g. total account equity).
/// The indicator tracks the running all-time peak and the deepest drawdown
/// seen so far, plus the cumulative net return relative to the *first*
/// observation:
///
/// ```text
/// peak = max(equity since start)
/// trough_dd = max((peak equity) / peak)
/// net_return = (equity_last / equity_first) 1
/// Recovery = net_return / trough_dd
/// ```
///
/// `Recovery > 1` means the strategy has earned more than it ever lost on
/// the way. A pure up-trend has no drawdown and the indicator reports `0.0`
/// (the ratio is undefined; zero by convention).
///
/// Cumulative-from-start rather than rolling-windowed: the user resets to
/// re-start the count. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RecoveryFactor};
///
/// let mut r = RecoveryFactor::new();
/// // Equity climbs, drops 20%, recovers and exceeds original peak.
/// for v in [100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0] {
/// r.update(v);
/// }
/// assert!(r.value().unwrap() > 0.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct RecoveryFactor {
first: f64,
last: f64,
peak: f64,
max_dd: f64,
seen: bool,
}
impl RecoveryFactor {
/// Construct a new Recovery Factor tracker.
pub const fn new() -> Self {
Self {
first: 0.0,
last: 0.0,
peak: f64::NEG_INFINITY,
max_dd: 0.0,
seen: false,
}
}
/// Current value if available.
pub fn value(&self) -> Option<f64> {
if !self.seen || self.first == 0.0 {
return None;
}
if self.max_dd == 0.0 {
return Some(0.0);
}
let net_return = (self.last / self.first) - 1.0;
Some(net_return / self.max_dd)
}
}
impl Indicator for RecoveryFactor {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.seen {
if input > self.peak {
self.peak = input;
}
if self.peak > 0.0 {
let dd = (self.peak - input) / self.peak;
if dd > self.max_dd {
self.max_dd = dd;
}
}
} else {
self.first = input;
self.peak = input;
self.seen = true;
}
self.last = input;
self.value()
}
fn reset(&mut self) {
self.first = 0.0;
self.last = 0.0;
self.peak = f64::NEG_INFINITY;
self.max_dd = 0.0;
self.seen = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.seen && self.first != 0.0
}
fn name(&self) -> &'static str {
"RecoveryFactor"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let r = RecoveryFactor::new();
assert_eq!(r.name(), "RecoveryFactor");
assert_eq!(r.warmup_period(), 1);
assert_eq!(r.value(), None);
}
#[test]
fn pure_uptrend_yields_zero() {
let mut r = RecoveryFactor::new();
for v in 1..=10 {
r.update(f64::from(v));
}
// max_dd == 0 -> 0 by convention.
assert_eq!(r.value(), Some(0.0));
}
#[test]
fn reference_value() {
// Start 100, peak 110, trough 88 -> max_dd = 0.2.
// End 130 -> net_return = 0.3 -> Recovery = 1.5.
let mut r = RecoveryFactor::new();
let out = r.batch(&[100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0]);
let last = out.last().copied().unwrap().unwrap();
assert_relative_eq!(last, 0.30 / 0.20, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut r = RecoveryFactor::new();
r.update(100.0);
r.update(90.0);
let v = r.value();
assert_eq!(r.update(f64::NAN), v);
assert_eq!(r.update(f64::INFINITY), v);
}
#[test]
fn first_value_alone_yields_zero() {
// First update: max_dd is still 0 -> 0 by convention; value defined.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(100.0), Some(0.0));
}
#[test]
fn first_zero_equity_keeps_value_none() {
// first == 0 means net-return division would be 0/0; indicator stays
// not-ready until a non-zero baseline is reset in.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(0.0), None);
assert!(!r.is_ready());
}
#[test]
fn reset_clears_state() {
let mut r = RecoveryFactor::new();
r.batch(&[100.0, 90.0, 80.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect();
let batch = RecoveryFactor::new().batch(&prices);
let mut s = RecoveryFactor::new();
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_positive_peak_skips_drawdown_calc() {
// All inputs <= 0 keep `peak` non-positive, so the guarded drawdown
// computation is skipped on every step. Exercises the `else` branch
// of `if self.peak > 0.0`.
let mut r = RecoveryFactor::new();
assert_eq!(r.update(-1.0), Some(0.0));
assert_eq!(r.update(-2.0), Some(0.0));
assert_eq!(r.update(-0.5), Some(0.0));
assert!(r.is_ready());
}
}
@@ -0,0 +1,220 @@
//! Rolling Sharpe Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Sharpe Ratio over `period` period-returns.
///
/// The input is treated as a single period-return (e.g. one day's percentage
/// return). Over the trailing window of `period` returns the indicator
/// computes:
///
/// ```text
/// Sharpe = (mean(returns) risk_free_per_period) / stddev(returns)
/// ```
///
/// `stddev` is the sample standard deviation with `n 1` in the denominator.
/// `risk_free_per_period` is the per-period risk-free rate the caller supplies
/// (e.g. `0.0` for excess-of-zero or a daily-equivalent rate to match the
/// return frequency). Wickra does not annualise: feed already-annualised
/// returns and supply an annual risk-free rate if you want an annualised
/// Sharpe.
///
/// A flat window has zero standard deviation and Sharpe is undefined; the
/// indicator returns `0.0` in that case rather than producing `NaN`.
///
/// Each `update` is O(1) — Welford-style running sums maintain `Σr`, `Σr²`
/// as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SharpeRatio};
///
/// let mut sr = SharpeRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = sr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SharpeRatio {
period: usize,
risk_free: f64,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl SharpeRatio {
/// Construct a new rolling Sharpe Ratio with the given window and
/// per-period risk-free rate.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (sample standard
/// deviation needs at least two observations).
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "sharpe ratio needs period >= 2",
});
}
Ok(Self {
period,
risk_free,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured per-period risk-free rate.
pub const fn risk_free(&self) -> f64 {
self.risk_free
}
}
impl Indicator for SharpeRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.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(input);
self.sum += input;
self.sum_sq += input * input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Sample variance with Bessel's correction.
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);
}
Some((mean - self.risk_free) / sd)
}
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 {
"SharpeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
SharpeRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
SharpeRatio::new(0, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let sr = SharpeRatio::new(20, 0.001).unwrap();
assert_eq!(sr.period(), 20);
assert_relative_eq!(sr.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(sr.name(), "SharpeRatio");
assert_eq!(sr.warmup_period(), 20);
}
#[test]
fn constant_returns_yield_zero() {
let mut sr = SharpeRatio::new(5, 0.0).unwrap();
let out = sr.batch(&[0.01; 10]);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reference_value() {
// returns = [0.01, 0.02, 0.03, 0.04], rf = 0.
// mean = 0.025, var = ((0.01-.025)^2 + (.02-.025)^2 + (.03-.025)^2
// + (.04-.025)^2) / 3 = 0.00016666..., sd = sqrt(0.000166..) =
// 0.01290994..., Sharpe = 0.025 / 0.01290994 ≈ 1.936491673.
let mut sr = SharpeRatio::new(4, 0.0).unwrap();
let out = sr.batch(&[0.01, 0.02, 0.03, 0.04]);
let expected = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut sr = SharpeRatio::new(3, 0.0).unwrap();
assert_eq!(sr.update(0.01), None);
assert_eq!(sr.update(f64::NAN), None);
assert_eq!(sr.update(0.02), None);
assert!(sr.update(0.03).is_some());
}
#[test]
fn warmup_returns_none() {
let mut sr = SharpeRatio::new(5, 0.0).unwrap();
for i in 0..4 {
assert_eq!(sr.update(f64::from(i) * 0.01), None);
}
assert!(sr.update(0.05).is_some());
}
#[test]
fn reset_clears_state() {
let mut sr = SharpeRatio::new(3, 0.0).unwrap();
sr.batch(&[0.01, 0.02, 0.03]);
assert!(sr.is_ready());
sr.reset();
assert!(!sr.is_ready());
assert_eq!(sr.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
.collect();
let batch = SharpeRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = SharpeRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,197 @@
//! Rolling Sortino Ratio — Sharpe with downside-only volatility.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Sortino Ratio.
///
/// Like the Sharpe Ratio but only penalises **downside** volatility — returns
/// below the minimum acceptable return (`mar`). The numerator is excess return
/// over `mar`; the denominator is the downside deviation:
///
/// ```text
/// downside_dev = sqrt( mean( min(0, r mar)² over period ) )
/// Sortino = (mean(r) mar) / downside_dev
/// ```
///
/// Downside variance uses the population formula (`n` in the denominator)
/// since the negative-shortfall samples are treated as the full population.
/// If every return in the window is ≥ `mar` the downside deviation is `0`
/// and the indicator returns `0.0` rather than `NaN`.
///
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SortinoRatio};
///
/// let mut sr = SortinoRatio::new(20, 0.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = sr.update((f64::from(i) * 0.1).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SortinoRatio {
period: usize,
mar: f64,
window: VecDeque<f64>,
sum: f64,
}
impl SortinoRatio {
/// Construct a new rolling Sortino Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize, mar: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "sortino ratio needs period >= 2",
});
}
Ok(Self {
period,
mar,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured minimum-acceptable return.
pub const fn mar(&self) -> f64 {
self.mar
}
}
impl Indicator for SortinoRatio {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let mut downside_sq = 0.0;
for &r in &self.window {
let d = r - self.mar;
if d < 0.0 {
downside_sq += d * d;
}
}
let dd = (downside_sq / n).sqrt();
if dd == 0.0 {
return Some(0.0);
}
Some((mean - self.mar) / dd)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SortinoRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
SortinoRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let s = SortinoRatio::new(10, 0.001).unwrap();
assert_eq!(s.period(), 10);
assert_relative_eq!(s.mar(), 0.001, epsilon = 1e-12);
assert_eq!(s.name(), "SortinoRatio");
assert_eq!(s.warmup_period(), 10);
}
#[test]
fn all_returns_above_mar_yields_zero_downside() {
let mut s = SortinoRatio::new(5, 0.0).unwrap();
let out = s.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
// Downside deviation is 0 -> indicator returns 0.0.
assert_eq!(out[4], Some(0.0));
}
#[test]
fn reference_value() {
// returns = [-0.02, 0.01, -0.01, 0.03], mar = 0.
// mean = 0.0025, downside_sq = (0.02)^2 + (0.01)^2 = 0.0005;
// downside_dev = sqrt(0.0005 / 4) = sqrt(0.000125) ≈ 0.01118033...
// Sortino = 0.0025 / 0.011180339887 ≈ 0.2236068.
let mut s = SortinoRatio::new(4, 0.0).unwrap();
let out = s.batch(&[-0.02, 0.01, -0.01, 0.03]);
let expected = 0.0025 / (0.000_125_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn ignores_non_finite_input() {
let mut s = SortinoRatio::new(3, 0.0).unwrap();
assert_eq!(s.update(f64::NAN), None);
assert_eq!(s.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut s = SortinoRatio::new(3, 0.0).unwrap();
s.batch(&[-0.01, -0.02, -0.005]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.3).sin() * 0.02)
.collect();
let batch = SortinoRatio::new(10, 0.0).unwrap().batch(&returns);
let mut s = SortinoRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,224 @@
//! Rolling Treynor Ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Treynor Ratio.
///
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
/// the trailing window of `period` pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ a·b ā·b̄
/// var_b = (1/n) · Σ b² b̄²
/// Beta = cov_ab / var_b
/// Treynor = (mean(asset) risk_free) / Beta
/// ```
///
/// Treynor is Sharpe's market-risk cousin: it divides excess return by the
/// asset's sensitivity to the benchmark (Beta) rather than by the asset's
/// own volatility. Useful for diversified portfolios where idiosyncratic
/// volatility has been mostly diversified away and the dominant remaining
/// risk is systematic / market exposure.
///
/// A flat benchmark window has zero variance and the indicator returns
/// `0.0` rather than `NaN`. A near-zero `Beta` makes the ratio explode by
/// construction; callers should treat extreme values with the usual care.
///
/// Each `update` is O(1) — running sums maintain `Σa`, `Σb`, `Σb²`, `Σa·b`
/// as the window slides.
#[derive(Debug, Clone)]
pub struct TreynorRatio {
period: usize,
risk_free: f64,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl TreynorRatio {
/// Construct a new rolling Treynor Ratio.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "treynor ratio needs period >= 2",
});
}
Ok(Self {
period,
risk_free,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured per-period risk-free rate.
pub const fn risk_free(&self) -> f64 {
self.risk_free
}
}
impl Indicator for TreynorRatio {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n) - mean_b * mean_b;
if var_b <= 0.0 {
return Some(0.0);
}
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
let beta = cov_ab / var_b;
if beta == 0.0 {
return Some(0.0);
}
Some((mean_a - self.risk_free) / beta)
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"TreynorRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
TreynorRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let t = TreynorRatio::new(20, 0.001).unwrap();
assert_eq!(t.period(), 20);
assert_relative_eq!(t.risk_free(), 0.001, epsilon = 1e-12);
assert_eq!(t.name(), "TreynorRatio");
assert_eq!(t.warmup_period(), 20);
}
#[test]
fn reference_beta_two_payoff() {
// a_i = 2 * b_i with non-zero mean.
// Beta should be 2; mean_a = 2 * mean_b; Treynor = mean_b.
let mut t = TreynorRatio::new(20, 0.0).unwrap();
let inputs: Vec<(f64, f64)> = (1..=20)
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
.collect();
let out = t.batch(&inputs);
let last = out[19].unwrap();
let expected = inputs.iter().map(|(_, b)| *b).sum::<f64>() / 20.0;
assert_relative_eq!(last, expected, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_yields_zero() {
// Benchmark all 0 -> var_b = 0 -> indicator returns 0.0.
let mut t = TreynorRatio::new(4, 0.0).unwrap();
let out = t.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.03, 0.0)]);
assert_eq!(out[3], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut t = TreynorRatio::new(3, 0.0).unwrap();
assert_eq!(t.update((f64::NAN, 0.0)), None);
assert_eq!(t.update((0.0, f64::INFINITY)), None);
}
#[test]
fn reset_clears_state() {
let mut t = TreynorRatio::new(3, 0.0).unwrap();
t.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update((0.01, 0.005)), None);
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<(f64, f64)> = (0..50)
.map(|i| {
let b = (f64::from(i) * 0.2).sin() * 0.01;
(1.5 * b + 0.001, b)
})
.collect();
let batch = TreynorRatio::new(10, 0.0).unwrap().batch(&inputs);
let mut s = TreynorRatio::new(10, 0.0).unwrap();
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn zero_beta_returns_zero() {
// Constant asset returns vs varying benchmark force cov(a,b) = 0,
// hence beta = 0 — the explicit zero-beta short-circuit.
let mut t = TreynorRatio::new(4, 0.0).unwrap();
let pairs: [(f64, f64); 4] = [(0.01, 0.005), (0.01, -0.002), (0.01, 0.001), (0.01, 0.003)];
let mut last = None;
for p in pairs {
last = t.update(p);
}
assert_eq!(last, Some(0.0));
}
}
@@ -0,0 +1,227 @@
//! Rolling historical Value-at-Risk (`VaR`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling historical Value-at-Risk.
///
/// Input is treated as a period return. Over the trailing window of `period`
/// returns the indicator reports the empirical lower-tail quantile at the
/// given `confidence` level (e.g. `0.95` = the 95 %-confident worst-case
/// loss). The output is the **magnitude** of that loss, sign-flipped to be a
/// non-negative number (so a 5 % `VaR` is reported as `0.05`, not `-0.05`):
///
/// ```text
/// q = (1 confidence)
/// VaR_t = percentile(returns over window, q · 100) if it is negative
/// VaR_t = 0 otherwise
/// ```
///
/// `percentile` uses linear interpolation between the two closest order
/// statistics ("type 7" in R / `NumPy` default). If the q-quantile of the
/// window is itself non-negative (a window where every return was at or above
/// zero) the indicator returns `0.0` — there is no loss to report.
///
/// Each `update` is O(period · log period) due to the window-sort. Good
/// enough for the typical `period ≤ 252` rolling-VaR workflow.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, ValueAtRisk};
///
/// let mut var = ValueAtRisk::new(100, 0.95).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = var.update((f64::from(i) * 0.1).sin() * 0.02);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ValueAtRisk {
period: usize,
confidence: f64,
window: VecDeque<f64>,
}
impl ValueAtRisk {
/// Construct a new rolling historical `VaR`.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
/// `confidence` is outside the open interval `(0, 1)`.
pub fn new(period: usize, confidence: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "value-at-risk needs period >= 2",
});
}
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
return Err(Error::InvalidPeriod {
message: "confidence must lie strictly between 0 and 1",
});
}
Ok(Self {
period,
confidence,
window: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Configured confidence level.
pub const fn confidence(&self) -> f64 {
self.confidence
}
}
/// Linear-interpolated percentile (type 7 / `NumPy` default) on a sorted slice.
fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
let n = sorted.len();
let pos = q * (n - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = pos - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
}
impl Indicator for ValueAtRisk {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = 1.0 - self.confidence;
let cut = percentile_sorted(&sorted, q);
// Loss magnitude (sign-flipped); 0 if quantile is non-negative.
Some((-cut).max(0.0))
}
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 {
"ValueAtRisk"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
ValueAtRisk::new(1, 0.95),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueAtRisk::new(20, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let v = ValueAtRisk::new(100, 0.95).unwrap();
assert_eq!(v.period(), 100);
assert_relative_eq!(v.confidence(), 0.95, epsilon = 1e-12);
assert_eq!(v.name(), "ValueAtRisk");
assert_eq!(v.warmup_period(), 100);
}
#[test]
fn reference_value() {
// returns = -5,-4,-3,-2,-1,0,1,2,3,4 (each *0.01), confidence 0.95.
// q = 0.05, sorted positions 0..9, pos = 0.05*9 = 0.45,
// -> -0.05 + (-0.04 - (-0.05))*0.45 = -0.05 + 0.0045 = -0.0455.
// VaR = 0.0455.
let mut v = ValueAtRisk::new(10, 0.95).unwrap();
let returns: Vec<f64> = (-5..5).map(|i| f64::from(i) * 0.01).collect();
let out = v.batch(&returns);
assert_relative_eq!(out[9].unwrap(), 0.0455, epsilon = 1e-9);
}
#[test]
fn all_positive_returns_yield_zero() {
let mut v = ValueAtRisk::new(5, 0.95).unwrap();
let out = v.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
assert_eq!(out[4], Some(0.0));
}
#[test]
fn ignores_non_finite_input() {
let mut v = ValueAtRisk::new(3, 0.95).unwrap();
assert_eq!(v.update(f64::NAN), None);
assert_eq!(v.update(f64::INFINITY), None);
}
#[test]
fn reset_clears_state() {
let mut v = ValueAtRisk::new(3, 0.95).unwrap();
v.batch(&[-0.01, -0.02, -0.03]);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
let batch = ValueAtRisk::new(10, 0.95).unwrap().batch(&returns);
let mut s = ValueAtRisk::new(10, 0.95).unwrap();
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn integer_position_quantile_branch() {
// period=5, confidence=0.75 -> q=0.25, n-1=4 -> pos=1.0 (integer),
// so the percentile helper takes the `lo == hi` branch.
let mut v = ValueAtRisk::new(5, 0.75).unwrap();
let out = v.batch(&[-0.05, -0.04, -0.03, -0.02, -0.01]);
// sorted = same order; sorted[1] = -0.04, so VaR = 0.04 exactly.
assert_relative_eq!(out[4].unwrap(), 0.04, epsilon = 1e-12);
}
}
+33 -30
View File
@@ -45,39 +45,42 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle,
Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon,
Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Beta, BollingerBands,
BollingerBandwidth, BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle,
Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput,
DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput,
DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse,
AverageDrawdown, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Beta,
BollingerBands, BollingerBandwidth, BollingerOutput, CalmarRatio, Camarilla,
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput,
HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InitialBalance,
InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma,
Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope,
MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
Marubozu, MassIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Mom,
MorningEveningStar, Natr, Nvi, Obv, OpeningRange, OpeningRangeOutput, ParkinsonVolatility,
PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, Psar,
Pvi, RSquared, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, ShootingStar, SineWave, Skewness, Sma, Smi, Smma,
SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput,
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic,
StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows, Tii, Trima,
Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex,
UltimateOscillator, ValueArea, ValueAreaOutput, Variance, VerticalHorizontalFilter, Vidya,
VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, T3,
+32 -16
View File
@@ -20,22 +20,23 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through
use std::hint::black_box;
use wickra::{
AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands,
Autocorrelation, BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots,
CoefficientOfVariation, CyberneticCycle, Decycler, DecyclerOscillator, DemandIndex,
DemarkPivots, DetrendedStdDev, Doji, DonchianStop, DoubleBollinger, EhlersStochastic, Ema,
EmpiricalModeDecomposition, Engulfing, Fama, FibonacciPivots, FisherTransform,
FractalChaosBands, Frama, GarmanKlassVolatility, Hammer, HeikinAshi, HiLoActivator,
HilbertDominantCycle, HurstChannel, HurstExponent, Ichimoku, Indicator, InitialBalance,
InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kurtosis, Kvo, LinRegChannel,
MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, McGinleyDynamic,
MedianAbsoluteDeviation, MorningEveningStar, Nvi, Obv, OpeningRange, ParkinsonVolatility,
PercentageTrailingStop, Pgo, Pvi, RSquared, RenkoTrailingStop, RogersSatchellVolatility,
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Skewness, Sma, StandardError,
StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei,
TdRiskLevel, TdSequential, TdSetup, ThreeInside, Tii, Tsv, TtmSqueeze, ValueArea, Variance,
Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma,
WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag,
Autocorrelation, BatchExt, BollingerBands, CalmarRatio, Camarilla, Candle, CenterOfGravity,
ClassicPivots, CoefficientOfVariation, CyberneticCycle, Decycler, DecyclerOscillator,
DemandIndex, DemarkPivots, DetrendedStdDev, Doji, DonchianStop, DoubleBollinger,
EhlersStochastic, Ema, EmpiricalModeDecomposition, Engulfing, Fama, FibonacciPivots,
FisherTransform, FractalChaosBands, Frama, GarmanKlassVolatility, Hammer, HeikinAshi,
HiLoActivator, HilbertDominantCycle, HurstChannel, HurstExponent, Ichimoku, Indicator,
InitialBalance, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kurtosis, Kvo,
LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, MorningEveningStar, Nvi, Obv, OpeningRange,
ParkinsonVolatility, PercentageTrailingStop, Pgo, ProfitFactor, Pvi, RSquared,
RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi,
SharpeRatio, SineWave, Skewness, Sma, StandardError, StandardErrorBands, StarcBands,
StepTrailingStop, Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup,
ThreeInside, Tii, Tsv, TtmSqueeze, ValueArea, ValueAtRisk, Variance, Vidya, VoltyStop,
VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots,
YangZhangVolatility, YoyoExit, ZigZag,
};
use wickra_data::csv::CandleReader;
@@ -408,6 +409,21 @@ fn benches(c: &mut Criterion) {
bench_candle_input(c, "opening_range", &candles, || {
OpeningRange::new(6).unwrap()
});
// --- Family 15: Risk / Performance Metrics ---
// Close-prices stand in for the equity curve / return stream; absolute
// numbers aren't meaningful here — what matters is the per-update cost.
bench_scalar(c, "sharpe_ratio", &closes, || {
SharpeRatio::new(20, 0.0).unwrap()
});
bench_scalar(c, "max_drawdown", &closes, || MaxDrawdown::new(20).unwrap());
bench_scalar(c, "profit_factor", &closes, || {
ProfitFactor::new(20).unwrap()
});
bench_scalar(c, "calmar_ratio", &closes, || CalmarRatio::new(20).unwrap());
bench_scalar(c, "value_at_risk", &closes, || {
ValueAtRisk::new(50, 0.95).unwrap()
});
}
/// Variant of `bench_scalar` for scalar-input indicators whose output is *not*
+7
View File
@@ -45,6 +45,13 @@ test = false
doc = false
bench = false
[[bin]]
name = "indicator_update_pair"
path = "fuzz_targets/indicator_update_pair.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tick_aggregator"
path = "fuzz_targets/tick_aggregator.rs"
+45 -12
View File
@@ -15,18 +15,20 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AdaptiveCycle, Alma, Apo, Autocorrelation, BatchExt, Beta, BollingerBands, CenterOfGravity,
Cfo, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, EhlersStochastic,
ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama,
HilbertDominantCycle, HistoricalVolatility, Hma, HurstExponent, Indicator,
InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Kst, Kurtosis, LaguerreRsi,
LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama,
McGinleyDynamic, MedianAbsoluteDeviation, Mom, PearsonCorrelation, PercentageTrailingStop,
Pmo, Ppo, RSquared, RenkoTrailingStop, Roc, RoofingFilter, Rsi, RviVolatility, SineWave,
Skewness, Sma, Smma, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev,
StepTrailingStop, StochRsi, SuperSmoother, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex,
Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema,
AdaptiveCycle, Alma, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands,
CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk,
ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev,
DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle,
HistoricalVolatility, Hma, HurstExponent, Indicator, InstantaneousTrendline,
InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle,
LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, Mom, OmegaRatio, PainIndex, PearsonCorrelation,
PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop,
Roc, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma,
SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev,
StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsi, UlcerIndex,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3,
};
/// Drive a single streaming + batch run through one scalar indicator. Marked
@@ -146,6 +148,37 @@ fuzz_target!(|data: Vec<f64>| {
drive(SineWave::new, &data);
drive(|| Fama::new(0.5, 0.05).unwrap(), &data);
// Family 15 — Risk / Performance metrics (scalar inputs).
drive(|| SharpeRatio::new(20, 0.0).unwrap(), &data);
drive(|| SortinoRatio::new(20, 0.0).unwrap(), &data);
drive(|| CalmarRatio::new(20).unwrap(), &data);
drive(|| OmegaRatio::new(20, 0.0).unwrap(), &data);
drive(|| MaxDrawdown::new(20).unwrap(), &data);
drive(|| AverageDrawdown::new(20).unwrap(), &data);
drive(|| PainIndex::new(20).unwrap(), &data);
drive(|| ValueAtRisk::new(20, 0.95).unwrap(), &data);
drive(|| ConditionalValueAtRisk::new(20, 0.95).unwrap(), &data);
drive(|| ProfitFactor::new(20).unwrap(), &data);
drive(|| GainLossRatio::new(20).unwrap(), &data);
drive(|| KellyCriterion::new(20).unwrap(), &data);
// RecoveryFactor and DrawdownDuration produce non-`f64` outputs / have
// no `period` knob, so they cannot use the `drive` helper directly.
{
let mut rf = RecoveryFactor::new();
for &x in &data {
let _ = rf.update(x);
}
let _ = RecoveryFactor::new().batch(&data);
}
{
let mut dd = DrawdownDuration::new();
for &x in &data {
let _ = dd.update(x);
}
let _ = DrawdownDuration::new().batch(&data);
}
// MACD, Bollinger Bands and MAMA have non-`f64` outputs, so they cannot
// use the generic `drive` helper above. Streaming + batch are still both
// exercised.
+10 -10
View File
@@ -25,18 +25,18 @@ use libfuzzer_sys::fuzz_target;
use wickra_core::{
AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, Adx, Adxr, Alligator,
AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit,
ChoppinessIndex, ClassicPivots, DemandIndex, DemarkPivots, Doji, Donchian, DonchianStop,
EaseOfMovement, Engulfing, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands,
GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HurstChannel, Ichimoku,
Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kvo, MarketFacilitationIndex,
Marubozu, MassIndex, MedianPrice, Mfi, MorningEveningStar, Natr, Nvi, Obv, OpeningRange,
AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex,
ClassicPivots, DemandIndex, DemarkPivots, Doji, Donchian, DonchianStop, EaseOfMovement,
Engulfing, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility,
Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, Indicator,
Inertia, InitialBalance, InvertedHammer, Keltner, Kvo, MarketFacilitationIndex, Marubozu,
MassIndex, MedianPrice, Mfi, MorningEveningStar, Natr, Nvi, Obv, OpeningRange,
ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RogersSatchellVolatility, RollingVwap,
Rvi, Rwi, ShootingStar, Smi, SpinningTop, StarcBands, Stochastic, SuperTrend, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection,
TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows,
TrueRange, Tsv, TtmSqueeze, Tweezer, TypicalPrice, UltimateOscillator, ValueArea, VoltyStop,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei,
TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeOutside, ThreeSoldiersOrCrows, TrueRange,
Tsv, TtmSqueeze, Tweezer, TypicalPrice, UltimateOscillator, ValueArea, VoltyStop,
VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend,
WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit,
ZigZag,
@@ -0,0 +1,39 @@
#![no_main]
//! Fuzz two-input `Indicator<(f64, f64)>` implementations with arbitrary
//! `(asset, benchmark)` return pairs.
//!
//! Each iteration consumes a byte stream and interprets it as a sequence of
//! `(f64, f64)` pairs (8 bytes per `f64`), then drives every two-series
//! indicator over the sequence both streaming and as a batch. No path may
//! panic.
use libfuzzer_sys::fuzz_target;
use wickra_core::{Alpha, BatchExt, Indicator, InformationRatio, TreynorRatio};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
where
I: Indicator<Input = (f64, f64), Output = f64> + BatchExt,
{
let mut streaming = make();
for &x in data {
let _ = streaming.update(x);
}
let _ = make().batch(data);
}
fuzz_target!(|data: &[u8]| {
// Pack two consecutive 8-byte chunks into one `(f64, f64)` pair.
let pairs: Vec<(f64, f64)> = data
.chunks_exact(16)
.map(|c| {
let a = f64::from_le_bytes(c[..8].try_into().expect("8 bytes"));
let b = f64::from_le_bytes(c[8..].try_into().expect("8 bytes"));
(a, b)
})
.collect();
drive(|| TreynorRatio::new(10, 0.0).unwrap(), &pairs);
drive(|| InformationRatio::new(10).unwrap(), &pairs);
drive(|| Alpha::new(10, 0.0).unwrap(), &pairs);
});