diff --git a/CHANGELOG.md b/CHANGELOG.md index ad54c70d..92a13ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **M2Measure** — M2 measure (Modigliani; Sharpe expressed in benchmark return units) (`M2Measure`). +- **UpsidePotentialRatio** — Upside Potential Ratio (upside mean over downside deviation) (`UpsidePotentialRatio`). +- **GainToPainRatio** — Gain-to-Pain Ratio (sum of returns over sum of losses) (`GainToPainRatio`). +- **CommonSenseRatio** — Common Sense Ratio (tail ratio times gain-to-pain) (`CommonSenseRatio`). +- **KRatio** — K-Ratio (Kestner; equity-curve slope over its standard error) (`KRatio`). +- **TailRatio** — Tail Ratio (95th over absolute 5th return percentile) (`TailRatio`). +- **MartinRatio** — Martin Ratio (Ulcer Performance Index; return over RMS drawdown) (`MartinRatio`). +- **BurkeRatio** — Burke Ratio (return over root-sum-squared drawdowns) (`BurkeRatio`). +- **SterlingRatio** — Sterling Ratio (mean return over average drawdown) (`SterlingRatio`). ## [0.7.2] - 2026-06-08 - **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`). diff --git a/README.md b/README.md index 68a49006..ed7d6a9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

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