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