diff --git a/CHANGELOG.md b/CHANGELOG.md index 39824c0..18f3c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` — 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`, diff --git a/README.md b/README.md index ebf2ad6..9892709 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 5e533c2..bfeedb9 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -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); diff --git a/bindings/node/index.js b/bindings/node/index.js index f786840..f110f31 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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 diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8fbc6aa..4ebc86f 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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 { + Ok(Self { + inner: wc::SharpeRatio::new(period as usize, risk_free).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::SortinoRatio::new(period as usize, mar).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::CalmarRatio::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::OmegaRatio::new(period as usize, threshold).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::MaxDrawdown::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::AverageDrawdown::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::PainIndex::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::ValueAtRisk::new(period as usize, confidence).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::ConditionalValueAtRisk::new(period as usize, confidence).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::ProfitFactor::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::GainLossRatio::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + Ok(Self { + inner: wc::KellyCriterion::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + 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 { + self.inner.update((asset, benchmark)) + } + #[napi] + pub fn batch(&mut self, asset: Vec, benchmark: Vec) -> napi::Result> { + 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 { + Ok(Self { + inner: wc::InformationRatio::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + #[napi] + pub fn batch(&mut self, asset: Vec, benchmark: Vec) -> napi::Result> { + 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 { + 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 { + self.inner.update((asset, benchmark)) + } + #[napi] + pub fn batch(&mut self, asset: Vec, benchmark: Vec) -> napi::Result> { + 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 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 9b1fabc..6255418 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -235,6 +235,24 @@ 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 @@ "SpinningTop", "ThreeInside", "ThreeOutside", + # Risk / Performance + "SharpeRatio", + "SortinoRatio", + "CalmarRatio", + "OmegaRatio", + "MaxDrawdown", + "AverageDrawdown", + "DrawdownDuration", + "PainIndex", + "ValueAtRisk", + "ConditionalValueAtRisk", + "ProfitFactor", + "GainLossRatio", + "RecoveryFactor", + "KellyCriterion", + "TreynorRatio", + "InformationRatio", + "Alpha", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 3034f40..afd5aad 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -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 { + Ok(Self { + inner: wc::SharpeRatio::new(period, risk_free).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::SortinoRatio::new(period, mar).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::CalmarRatio::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::OmegaRatio::new(period, threshold).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::MaxDrawdown::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::AverageDrawdown::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let out: Vec = 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 { + Ok(Self { + inner: wc::PainIndex::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::ValueAtRisk::new(period, confidence).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::ConditionalValueAtRisk::new(period, confidence).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::ProfitFactor::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::GainLossRatio::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::KellyCriterion::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?, + }) + } + fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::InformationRatio::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 { + Ok(Self { + inner: wc::Alpha::new(period, risk_free).map_err(map_err)?, + }) + } + fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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::()?; m.add_class::()?; m.add_class::()?; + // Family 15: Risk / Performance metrics. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index e410c4a..e629b9e 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -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] diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 9586e51..03e9c4e 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -17,13 +17,17 @@ 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 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (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 diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 69d5c88..25e1159 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -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 { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let out: Vec = 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 { + 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 { + Ok(Self { + inner: wc::TreynorRatio::new(period, risk_free).map_err(map_err)?, + }) + } + pub fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result { + 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 { + Ok(Self { + inner: wc::InformationRatio::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result { + 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 { + Ok(Self { + inner: wc::Alpha::new(period, risk_free).map_err(map_err)?, + }) + } + pub fn update(&mut self, asset: f64, benchmark: f64) -> Option { + self.inner.update((asset, benchmark)) + } + pub fn batch(&mut self, asset: &[f64], benchmark: &[f64]) -> Result { + 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() + } +} diff --git a/crates/wickra-core/src/indicators/alpha.rs b/crates/wickra-core/src/indicators/alpha.rs new file mode 100644 index 0000000..294b8a7 --- /dev/null +++ b/crates/wickra-core/src/indicators/alpha.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/wickra-core/src/indicators/average_drawdown.rs b/crates/wickra-core/src/indicators/average_drawdown.rs new file mode 100644 index 0000000..d9aa2b2 --- /dev/null +++ b/crates/wickra-core/src/indicators/average_drawdown.rs @@ -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, +} + +impl AverageDrawdown { + /// Construct a new rolling Average Drawdown. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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::>()); + 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 = (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); + } + } +} diff --git a/crates/wickra-core/src/indicators/calmar_ratio.rs b/crates/wickra-core/src/indicators/calmar_ratio.rs new file mode 100644 index 0000000..531244a --- /dev/null +++ b/crates/wickra-core/src/indicators/calmar_ratio.rs @@ -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, + sum: f64, +} + +impl CalmarRatio { + /// Construct a new rolling Calmar Ratio. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/conditional_value_at_risk.rs b/crates/wickra-core/src/indicators/conditional_value_at_risk.rs new file mode 100644 index 0000000..4565c52 --- /dev/null +++ b/crates/wickra-core/src/indicators/conditional_value_at_risk.rs @@ -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, +} + +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 { + 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 { + 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 = 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::() / 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 = (-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 = 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/drawdown_duration.rs b/crates/wickra-core/src/indicators/drawdown_duration.rs new file mode 100644 index 0000000..6412e83 --- /dev/null +++ b/crates/wickra-core/src/indicators/drawdown_duration.rs @@ -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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/gain_loss_ratio.rs b/crates/wickra-core/src/indicators/gain_loss_ratio.rs new file mode 100644 index 0000000..23d333c --- /dev/null +++ b/crates/wickra-core/src/indicators/gain_loss_ratio.rs @@ -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, +} + +impl GainLossRatio { + /// Construct a new rolling Gain/Loss Ratio. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/information_ratio.rs b/crates/wickra-core/src/indicators/information_ratio.rs new file mode 100644 index 0000000..339beca --- /dev/null +++ b/crates/wickra-core/src/indicators/information_ratio.rs @@ -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, + 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 { + 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 { + 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); + } +} diff --git a/crates/wickra-core/src/indicators/kelly_criterion.rs b/crates/wickra-core/src/indicators/kelly_criterion.rs new file mode 100644 index 0000000..63432c3 --- /dev/null +++ b/crates/wickra-core/src/indicators/kelly_criterion.rs @@ -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, +} + +impl KellyCriterion { + /// Construct a new rolling Kelly Criterion. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/max_drawdown.rs b/crates/wickra-core/src/indicators/max_drawdown.rs new file mode 100644 index 0000000..8e32cb2 --- /dev/null +++ b/crates/wickra-core/src/indicators/max_drawdown.rs @@ -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, + last: Option, +} + +impl MaxDrawdown { + /// Construct a new rolling Max Drawdown. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + self.last + } +} + +impl Indicator for MaxDrawdown { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + 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::>()); + 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 = (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); + } + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index b0ecc56..73512d5 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/omega_ratio.rs b/crates/wickra-core/src/indicators/omega_ratio.rs new file mode 100644 index 0000000..faaa892 --- /dev/null +++ b/crates/wickra-core/src/indicators/omega_ratio.rs @@ -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, +} + +impl OmegaRatio { + /// Construct a new rolling Omega Ratio. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize, threshold: f64) -> Result { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/pain_index.rs b/crates/wickra-core/src/indicators/pain_index.rs new file mode 100644 index 0000000..49bc1c4 --- /dev/null +++ b/crates/wickra-core/src/indicators/pain_index.rs @@ -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, +} + +impl PainIndex { + /// Construct a new rolling Pain Index. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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::>()); + 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 = (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); + } + } +} diff --git a/crates/wickra-core/src/indicators/profit_factor.rs b/crates/wickra-core/src/indicators/profit_factor.rs new file mode 100644 index 0000000..1dd097d --- /dev/null +++ b/crates/wickra-core/src/indicators/profit_factor.rs @@ -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, +} + +impl ProfitFactor { + /// Construct a new rolling Profit Factor. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/recovery_factor.rs b/crates/wickra-core/src/indicators/recovery_factor.rs new file mode 100644 index 0000000..9a0ef7c --- /dev/null +++ b/crates/wickra-core/src/indicators/recovery_factor.rs @@ -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 { + 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 { + 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 = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/sharpe_ratio.rs b/crates/wickra-core/src/indicators/sharpe_ratio.rs new file mode 100644 index 0000000..251f954 --- /dev/null +++ b/crates/wickra-core/src/indicators/sharpe_ratio.rs @@ -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, + 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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/sortino_ratio.rs b/crates/wickra-core/src/indicators/sortino_ratio.rs new file mode 100644 index 0000000..e0e2543 --- /dev/null +++ b/crates/wickra-core/src/indicators/sortino_ratio.rs @@ -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, + 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 { + 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 { + 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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/treynor_ratio.rs b/crates/wickra-core/src/indicators/treynor_ratio.rs new file mode 100644 index 0000000..52c89ed --- /dev/null +++ b/crates/wickra-core/src/indicators/treynor_ratio.rs @@ -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 { + 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 { + 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::() / 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)); + } +} diff --git a/crates/wickra-core/src/indicators/value_at_risk.rs b/crates/wickra-core/src/indicators/value_at_risk.rs new file mode 100644 index 0000000..2f3a5d9 --- /dev/null +++ b/crates/wickra-core/src/indicators/value_at_risk.rs @@ -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, +} + +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 { + 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 { + 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 = 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 = (-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 = (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); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 0f6ad52..dfe7c3d 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -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, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 4e3f11b..f897da3 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -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* diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 9c36c6a..e112351 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -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" diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index b25aea0..e71dfad 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -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| { 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. diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index e548b5d..1bb6a11 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -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, diff --git a/fuzz/fuzz_targets/indicator_update_pair.rs b/fuzz/fuzz_targets/indicator_update_pair.rs new file mode 100644 index 0000000..2af4f4e --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_pair.rs @@ -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(make: impl Fn() -> I, data: &[(f64, f64)]) +where + I: Indicator + 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); +});